From 80c21d4b34454659f1f9a773f156daa113381257 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 3 Oct 2018 16:19:01 +0000 Subject: Added Permission for Helper --- bot/cogs/bot.py | 21 +++++++++++++-------- bot/constants.py | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index fcc642313..2173475c3 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -3,7 +3,7 @@ import logging import re import time -from discord import Embed, Member, Message, Reaction +from discord import Embed, Message, RawReactionActionEvent from discord.ext.commands import Bot, Context, command, group from dulwich.repo import Repo @@ -369,16 +369,21 @@ class Bot: await bot_message.delete() del self.codeblock_message_ids[after.id] - async def on_reaction_add(self, reaction: Reaction, user: Member): + async def on_raw_reaction_add(self, payload: RawReactionActionEvent): # Ignores reactions added by the bot or added to non-codeblock correction embed messages - if user.bot or reaction.message.id not in self.codeblock_message_ids.values(): + # Also ignores the reaction if the user can't be loaded + user = self.get_user(payload.user_id) + if user is None: + return + if user.bot or payload.message_id not in self.codeblock_message_ids.values(): return # Finds the appropriate bot message/ user message pair and assigns them to variables for user_message_id, bot_message_id in self.codeblock_message_ids.items(): - if bot_message_id == reaction.message.id: - user_message = await reaction.message.channel.get_message(user_message_id) - bot_message = await reaction.message.channel.get_message(bot_message_id) + if bot_message_id == payload.message_id: + channel = self.get_channel(payload.channel_id) + user_message = await channel.get_message(user_message_id) + bot_message = await channel.get_message(bot_message_id) break # If the reaction was clicked on by the author of the user message, deletes the bot message @@ -387,9 +392,9 @@ class Bot: del self.codeblock_message_ids[user_message_id] return - # If the reaction was clicked by staff (mod or higher), deletes the bot message + # If the reaction was clicked by staff (helper or higher), deletes the bot message for role in user.roles: - if role.id in (Roles.owner, Roles.admin, Roles.moderator): + if role.id in (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers): await bot_message.delete() del self.codeblock_message_ids[user_message_id] return diff --git a/bot/constants.py b/bot/constants.py index 68fbc2bc4..e718eb059 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -343,6 +343,7 @@ class Roles(metaclass=YAMLGetter): owner: int verified: int muted: int + helpers: int class Guild(metaclass=YAMLGetter): -- cgit v1.2.3 From 0b7e1d9bef3d529c679a99d5518ac8c935d8a134 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Thu, 4 Oct 2018 12:53:23 +0000 Subject: Hem's fixes. --- bot/cogs/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 2173475c3..acbc29f98 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -372,7 +372,7 @@ class Bot: async def on_raw_reaction_add(self, payload: RawReactionActionEvent): # Ignores reactions added by the bot or added to non-codeblock correction embed messages # Also ignores the reaction if the user can't be loaded - user = self.get_user(payload.user_id) + user = self.bot.get_user(payload.user_id) if user is None: return if user.bot or payload.message_id not in self.codeblock_message_ids.values(): @@ -381,7 +381,7 @@ class Bot: # Finds the appropriate bot message/ user message pair and assigns them to variables for user_message_id, bot_message_id in self.codeblock_message_ids.items(): if bot_message_id == payload.message_id: - channel = self.get_channel(payload.channel_id) + channel = self.bot.get_channel(payload.channel_id) user_message = await channel.get_message(user_message_id) bot_message = await channel.get_message(bot_message_id) break -- cgit v1.2.3 From 371cda6d7e3ee535089be1fa53d1358fd43fb314 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sat, 6 Oct 2018 20:08:12 +0000 Subject: Infraction search improvements --- bot/cogs/moderation.py | 160 ++++++++++++++++++++++++++----------------------- bot/converters.py | 9 ++- 2 files changed, 90 insertions(+), 79 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index ee28a3600..79e7f0f9f 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -488,7 +488,7 @@ class Moderation: # region: Edit infraction commands @with_role(*MODERATION_ROLES) - @group(name='infraction', aliases=('infr',)) + @group(name='infraction', aliases=('infr', 'infractions', 'inf')) async def infraction_group(self, ctx: Context): """Infraction manipulation commands.""" @@ -654,70 +654,88 @@ class Moderation: # region: Search infractions @with_role(*MODERATION_ROLES) - @infraction_group.command(name="search") - async def search(self, ctx, arg: InfractionSearchQuery): + @infraction_group.group(name="search", invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery): """ Searches for infractions in the database. - :param arg: Either a user or a reason string. If a string, you can use the Re2 matching syntax. - """ - - if isinstance(arg, User): - user: User = arg - # get infractions for this user - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user.id - ), - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception("There was an error fetching infractions.") - await ctx.send(":x: There was an error fetching infraction.") - return + """ - if not infraction_list: - await ctx.send(f":warning: No infractions found for {user}.") - return + if isinstance(query, User): + await ctx.invoke(self.search_user, query) - embed = Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", - colour=Colour.orange() + else: + await ctx.invoke(self.search_reason, query) + + @with_role(*MODERATION_ROLES) + @infraction_search_group.command(name="user", aliases=("member", "id")) + async def search_user(self, ctx, user: User): + """ + Search for infractions by member. + """ + + try: + response = await self.bot.http_session.get( + URLs.site_infractions_user.format( + user_id=user.id + ), + headers=self.headers ) + infraction_list = await response.json() + except ClientError: + log.exception(f"Failed to fetch infractions for user {user} ({user.id}).") + await ctx.send(":x: An error occurred while fetching infractions.") + return - elif isinstance(arg, str): - # search by reason - try: - response = await self.bot.http_session.get( - URLs.site_infractions, - headers=self.headers, - params={"search": arg} - ) - infraction_list = await response.json() - except ClientError: - log.exception("There was an error fetching infractions.") - await ctx.send(":x: There was an error fetching infraction.") - return + embed = Embed( + title=f"Infractions for {user} ({len(infraction_list)} total)", + colour=Colour.orange() + ) - if not infraction_list: - await ctx.send(f":warning: No infractions matching `{arg}`.") - return + await self.send_infraction_list(ctx, embed, infraction_list) + + @with_role(*MODERATION_ROLES) + @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) + async def search_reason(self, ctx, reason: str): + """ + Search for infractions by their reason. Use Re2 for matching. + """ - embed = Embed( - title=f"Infractions matching `{arg}` ({len(infraction_list)} total)", - colour=Colour.orange() + try: + response = await self.bot.http_session.get( + URLs.site_infractions, + params={"search": reason}, + headers=self.headers ) + infraction_list = await response.json() + except ClientError: + log.exception(f"Failed to fetch infractions matching reason `{reason}`.") + await ctx.send(":x: An error occurred while fetching infractions.") + return - else: - await ctx.send(":x: Invalid infraction search query.") + embed = Embed( + title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + colour=Colour.orange() + ) + + await self.send_infraction_list(ctx, embed, infraction_list) + + # endregion + # region: Utility functions + + async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list): + + if not infractions: + await ctx.send(f":warning: No infractions could be found for that query.") return + lines = [] + for infraction in infractions: + lines.append( + self._infraction_to_string(infraction) + ) + await LinePaginator.paginate( - lines=( - self._infraction_to_string(infraction_object, show_user=isinstance(arg, str)) - for infraction_object in infraction_list - ), + lines, ctx=ctx, embed=embed, empty=True, @@ -725,9 +743,6 @@ class Moderation: max_size=1000 ) - # endregion - # region: Utility functions - def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): """ Schedules a task to expire a temporary infraction. @@ -815,30 +830,27 @@ class Moderation: } ) - def _infraction_to_string(self, infraction_object, show_user=False): + def _infraction_to_string(self, infraction_object): actor_id = int(infraction_object["actor"]["user_id"]) guild: Guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) active = infraction_object["active"] is True + user_id = int(infraction_object["user"]["user_id"]) - lines = [ - "**===============**" if active else "===============", - "Status: {0}".format("__**Active**__" if active else "Inactive"), - "Type: **{0}**".format(infraction_object["type"]), - "Reason: {0}".format(infraction_object["reason"] or "*None*"), - "Created: {0}".format(infraction_object["inserted_at"]), - "Expires: {0}".format(infraction_object["expires_at"] or "*Permanent*"), - "Actor: {0}".format(actor.mention if actor else actor_id), - "ID: `{0}`".format(infraction_object["id"]), - "**===============**" if active else "===============" - ] - - if show_user: - user_id = int(infraction_object["user"]["user_id"]) - user = self.bot.get_user(user_id) - lines.insert(1, "User: {0}".format(user.mention if user else user_id)) - - return "\n".join(lines) + lines = textwrap.dedent(f""" + {"**===============**" if active else "==============="} + Status: {"__**Active**__" if active else "Inactive"} + User: {self.bot.get_user(user_id)} (`{user_id}`) + Type: **{infraction_object["type"]}** + Reason: {infraction_object["reason"] or "*None*"} + Created: {infraction_object["inserted_at"]} + Expires: {infraction_object["expires_at"] or "*Permanent*"} + Actor: {actor.mention if actor else actor_id} + ID: `{infraction_object["id"]}` + {"**===============**" if active else "==============="} + """) + + return lines.strip() # endregion diff --git a/bot/converters.py b/bot/converters.py index 3def4b07a..c8bc75715 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -4,7 +4,7 @@ from ssl import CertificateError import discord from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector -from discord.ext.commands import BadArgument, Converter, UserConverter +from discord.ext.commands import BadArgument, Converter from fuzzywuzzy import fuzz from bot.constants import DEBUG_MODE, Keys, URLs @@ -167,11 +167,10 @@ class InfractionSearchQuery(Converter): @staticmethod async def convert(ctx, arg): try: - user_converter = UserConverter() - user = await user_converter.convert(ctx, arg) - except Exception: + maybe_snowflake = arg.strip("<@!>") + return await ctx.bot.get_user_info(maybe_snowflake) + except (discord.NotFound, discord.HTTPException): return arg - return user or arg class Subreddit(Converter): -- cgit v1.2.3 From 65cfc33a3e5894056f4babf857d56ab880b86dc6 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sat, 6 Oct 2018 21:22:21 +0000 Subject: Add Reminders cog. --- bot/__main__.py | 1 + bot/cogs/moderation.py | 29 +--- bot/cogs/reminders.py | 442 ++++++++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 7 +- bot/utils/scheduling.py | 22 +++ bot/utils/time.py | 22 +++ config-default.yml | 6 + 7 files changed, 503 insertions(+), 26 deletions(-) create mode 100644 bot/cogs/reminders.py create mode 100644 bot/utils/scheduling.py diff --git a/bot/__main__.py b/bot/__main__.py index e4dbbfcde..602846ded 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -65,6 +65,7 @@ bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") +bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.snekbox") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 79e7f0f9f..4a0e4c0f4 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,5 +1,4 @@ import asyncio -import datetime import logging import textwrap from typing import Dict @@ -14,6 +13,8 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs from bot.converters import InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.scheduling import create_task +from bot.utils.time import parse_rfc1123, wait_until log = logging.getLogger(__name__) @@ -754,10 +755,7 @@ class Moderation: if infraction_id in self.expiration_tasks: return - task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop) - - # Silently ignore exceptions in a callback (handles the CancelledError nonsense) - task.add_done_callback(_silent_exception) + task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object)) self.expiration_tasks[infraction_id] = task @@ -787,12 +785,7 @@ class Moderation: # transform expiration to delay in seconds expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) - delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc) - delay_seconds = delay.total_seconds() - - if delay_seconds > 1.0: - log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds") - await asyncio.sleep(delay_seconds) + await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") await self._deactivate_infraction(infraction_object) @@ -855,20 +848,6 @@ class Moderation: # endregion -RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" - - -def parse_rfc1123(time_str): - return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) - - -def _silent_exception(future): - try: - future.exception() - except Exception: # noqa: S110 - pass - - def setup(bot): bot.add_cog(Moderation(bot)) log.info("Cog loaded: Moderation") diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py new file mode 100644 index 000000000..98d7942b3 --- /dev/null +++ b/bot/cogs/reminders.py @@ -0,0 +1,442 @@ +import asyncio +import datetime +import logging +import random +import textwrap + +from aiohttp import ClientResponseError +from dateutil.relativedelta import relativedelta +from discord import Colour, Embed +from discord.ext.commands import Bot, Context, group + +from bot.constants import ( + Channels, Icons, Keys, NEGATIVE_REPLIES, + POSITIVE_REPLIES, Roles, URLs +) +from bot.pagination import LinePaginator +from bot.utils.scheduling import create_task +from bot.utils.time import humanize_delta, parse_rfc1123, wait_until + +log = logging.getLogger(__name__) + +STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) +WHITELISTED_CHANNELS = (Channels.bot,) +MAXIMUM_REMINDERS = 5 + + +# The scheduling parts of this cog are pretty much directly copied +# from the moderation cog. I'll be working on making it more +# webscale:tm: as soon as possible, because this is a mess :D +class Reminders: + + def __init__(self, bot: Bot): + self.bot = bot + + self.headers = {"X-API-Key": Keys.site_api} + self.reminder_tasks = {} + + async def on_ready(self): + # Get all the current reminders for re-scheduling + response = await self.bot.http_session.get( + url=URLs.site_reminders_api, + headers=self.headers + ) + + response_data = await response.json() + + # Find the current time, timezone-aware. + now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + loop = asyncio.get_event_loop() + + for reminder in response_data["reminders"]: + remind_at = parse_rfc1123(reminder["remind_at"]) + + # If the reminder is already overdue ... + if remind_at < now: + late = relativedelta(now, remind_at) + await self.send_reminder(reminder, late) + + else: + self.schedule_reminder(loop, reminder) + + @staticmethod + async def _send_confirmation(ctx: Context, response: dict, on_success: str): + """ + Send an embed confirming whether or not a change was made successfully. + + :return: A Boolean value indicating whether it failed (True) or passed (False) + """ + + embed = Embed() + + if not response.get("success"): + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = response.get("error_message", "An unexpected error occurred.") + + log.warn(f"Unable to create/edit/delete a reminder. Response: {response}") + failed = True + + else: + embed.colour = Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success + + failed = False + + await ctx.send(embed=embed) + return failed + + def schedule_reminder(self, loop: asyncio.AbstractEventLoop, reminder): + """ + Schedule a reminder from the bot at the requested time. + + :param loop: the asyncio event loop + :param reminder: the data of the reminder. + """ + + # Avoid duplicate schedules, just in case. + reminder_id = reminder["id"] + if reminder_id in self.reminder_tasks: + return + + # Make a scheduled task and add it to the list + task: asyncio.Task = create_task(loop, self._scheduled_reminder(reminder)) + self.reminder_tasks[reminder_id] = task + + async def _scheduled_reminder(self, reminder): + """ + A coroutine which sends the reminder once the time is reached. + + :param reminder: the data of the reminder. + :return: + """ + + reminder_id = reminder["id"] + reminder_datetime = parse_rfc1123(reminder["remind_at"]) + + # Send the reminder message once the desired duration has passed + await wait_until(reminder_datetime) + await self.send_reminder(reminder) + + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder) + + # Now we can begone with it from our schedule list. + self.cancel_reminder(reminder_id) + + def cancel_reminder(self, reminder_id: str): + """ + Un-schedules a task to send a reminder. + + :param reminder_id: the ID of the reminder in question + """ + + task = self.reminder_tasks.get(reminder_id) + + if task is None: + log.warning(f"Failed to unschedule {reminder_id}: no task found.") + return + + task.cancel() + log.debug(f"Unscheduled {reminder_id}.") + del self.reminder_tasks[reminder_id] + + async def _delete_reminder(self, reminder_id: str): + """ + Delete a reminder from the database, given its ID. + + :param reminder_id: The ID of the reminder. + """ + + # The API requires a list, so let's give it one :) + json_data = { + "reminders": [ + reminder_id + ] + } + + await self.bot.http_session.delete( + url=URLs.site_reminders_api, + headers=self.headers, + json=json_data + ) + + # Now we can remove it from the schedule list + self.cancel_reminder(reminder_id) + + async def _reschedule_reminder(self, reminder): + """ + Reschedule a reminder object. + + :param reminder: The reminder to be rescheduled. + """ + + loop = asyncio.get_event_loop() + + self.cancel_reminder(reminder["id"]) + self.schedule_reminder(loop, reminder) + + async def send_reminder(self, reminder, late: relativedelta = None): + """ + Send the reminder. + + :param reminder: The data about the reminder. + :param late: How late the reminder is (if at all) + """ + + channel = self.bot.get_channel(int(reminder["channel_id"])) + user = self.bot.get_user(int(reminder["user_id"])) + + embed = Embed() + embed.colour = Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`" + + if late: + embed.colour = Colour.red() + embed.set_author( + icon_url=Icons.remind_red, + name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + ) + + await channel.send( + content=user.mention, + embed=embed + ) + await self._delete_reminder(reminder["id"]) + + @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) + async def remind_group(self, ctx: Context, duration: str, *, content: str): + """ + Commands for managing your reminders. + """ + + await ctx.invoke(self.new_reminder, duration=duration, content=content) + + @remind_group.command(name="new", aliases=("add", "create")) + async def new_reminder(self, ctx: Context, duration: str, *, content: str): + """ + Set yourself a simple reminder. + """ + + embed = Embed() + + # Make sure the reminder should actually be made. + if ctx.author.top_role.id not in STAFF_ROLES: + + # If they don't have permission to set a reminder in this channel + if ctx.channel.id not in WHITELISTED_CHANNELS: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = "Sorry, you can't do that here!" + + return await ctx.send(embed=embed) + + # Get their current active reminders + response = await self.bot.http_session.get( + url=URLs.site_reminders_user_api.format(user_id=ctx.author.id), + headers=self.headers + ) + + active_reminders = await response.json() + + # Let's limit this, so we don't get 10 000 + # reminders from kip or something like that :P + if len(active_reminders) > MAXIMUM_REMINDERS: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = "You have too many active reminders!" + + return await ctx.send(embed=embed) + + # Now we can attempt to actually set the reminder. + try: + response = await self.bot.http_session.post( + url=URLs.site_reminders_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "duration": duration, + "content": content, + "channel_id": str(ctx.channel.id) + } + ) + + response_data = await response.json() + + # AFAIK only happens if the user enters, like, a quintillion weeks + except ClientResponseError: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = ( + "An error occurred while adding your reminder to the database. " + "Did you enter a reasonable duration?" + ) + + log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.") + + return await ctx.send(embed=embed) + + # Confirm to the user whether or not it worked. + failed = await self._send_confirmation( + ctx, response_data, + on_success="Your reminder has been created successfully!" + ) + + # If it worked, schedule the reminder. + if not failed: + loop = asyncio.get_event_loop() + self.schedule_reminder(loop=loop, reminder=response_data["reminder"]) + + @remind_group.command(name="list") + async def list_reminders(self, ctx: Context): + """ + View a paginated embed of all reminders for your user. + """ + + # Get all the user's reminders from the database. + response = await self.bot.http_session.get( + url=URLs.site_reminders_user_api, + params={"user_id": str(ctx.author.id)}, + headers=self.headers + ) + + data = await response.json() + now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + + # Make a list of tuples so it can be sorted by time. + reminders = [ + (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"] + ] + + reminders.sort(key=lambda rem: rem[1]) + + lines = [] + + for index, (content, remind_at, friendly_id) in enumerate(reminders): + # Parse and humanize the time, make it pretty :D + remind_datetime = parse_rfc1123(remind_at) + time = humanize_delta(relativedelta(remind_datetime, now)) + + text = textwrap.dedent(f""" + **Reminder #{index}:** *expires in {time}* (ID: {friendly_id}) + {content} + """).strip() + + lines.append(text) + + embed = Embed() + embed.colour = Colour.blurple() + embed.title = f"Reminders for {ctx.author}" + + # Remind the user that they have no reminders :^) + if not lines: + embed.description = "No active reminders could be found." + return await ctx.send(embed=embed) + + # Construct the embed and paginate it. + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + lines, + ctx, embed, + max_lines=3, + empty=True + ) + + @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) + async def edit_reminder_group(self, ctx: Context): + """ + Commands for modifying your current reminders. + """ + + await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") + + @edit_reminder_group.command(name="duration", aliases=("time",)) + async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str): + """ + Edit one of your reminders' duration. + """ + + # Send the request to update the reminder in the database + response = await self.bot.http_session.patch( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id, + "duration": duration + } + ) + + # Send a confirmation message to the channel + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been edited successfully!" + ) + + if not failed: + await self._reschedule_reminder(response_data["reminder"]) + + @edit_reminder_group.command(name="content", aliases=("reason",)) + async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str): + """ + Edit one of your reminders' content. + """ + + # Send the request to update the reminder in the database + response = await self.bot.http_session.patch( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id, + "content": content + } + ) + + # Send a confirmation message to the channel + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been edited successfully!" + ) + + if not failed: + await self._reschedule_reminder(response_data["reminder"]) + + @remind_group.command("delete", aliases=("remove",)) + async def delete_reminder(self, ctx: Context, friendly_id: str): + """ + Delete one of your active reminders. + """ + + # Send the request to delete the reminder from the database + response = await self.bot.http_session.delete( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id + } + ) + + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been deleted successfully!" + ) + + if not failed: + self.cancel_reminder(response_data["reminder_id"]) + + +def setup(bot: Bot): + bot.add_cog(Reminders(bot)) + log.info("Cog loaded: Reminders") diff --git a/bot/constants.py b/bot/constants.py index e718eb059..2433d15ef 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -289,6 +289,10 @@ class Icons(metaclass=YAMLGetter): pencil: str + remind_blurple: str + remind_green: str + remind_red: str + class CleanMessages(metaclass=YAMLGetter): section = "bot" @@ -342,7 +346,6 @@ class Roles(metaclass=YAMLGetter): muted: int owner: int verified: int - muted: int helpers: int @@ -397,6 +400,8 @@ class URLs(metaclass=YAMLGetter): site_logs_view: str site_names_api: str site_quiz_api: str + site_reminders_api: str + site_reminders_user_api: str site_schema: str site_settings_api: str site_special_api: str diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py new file mode 100644 index 000000000..f9b844046 --- /dev/null +++ b/bot/utils/scheduling.py @@ -0,0 +1,22 @@ +import asyncio +import contextlib + + +def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): + """ + Creates an asyncio.Task object from a coroutine or future object. + + :param loop: the asyncio event loop. + :param coro_or_future: the coroutine or future object to be scheduled. + """ + + task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) + + # Silently ignore exceptions in a callback (handles the CancelledError nonsense) + task.add_done_callback(_silent_exception) + return task + + +def _silent_exception(future): + with contextlib.suppress(Exception): + future.exception() diff --git a/bot/utils/time.py b/bot/utils/time.py index 77cef4670..8e5d4e1bd 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,7 +1,10 @@ +import asyncio import datetime from dateutil.relativedelta import relativedelta +RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" + def _stringify_time_unit(value: int, unit: str): """ @@ -89,3 +92,22 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max humanized = humanize_delta(delta, precision, max_units) return f"{humanized} ago" + + +def parse_rfc1123(time_str): + return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) + + +# Hey, this could actually be used in the off_topic_names and reddit cogs :) +async def wait_until(time: datetime.datetime): + """ + Wait until a given time. + + :param time: A datetime.datetime object to wait until. + """ + + delay = time - datetime.datetime.now(tz=datetime.timezone.utc) + delay_seconds = delay.total_seconds() + + if delay_seconds > 1.0: + await asyncio.sleep(delay_seconds) diff --git a/config-default.yml b/config-default.yml index ce7639186..7130eb540 100644 --- a/config-default.yml +++ b/config-default.yml @@ -72,6 +72,10 @@ style: pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" + remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" + remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" + remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" + guild: id: 267624335836053506 @@ -225,6 +229,8 @@ urls: site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"] site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"] + site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] + site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"] site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] -- cgit v1.2.3 From d079b3d34ba1fb045e63d332b70bc91940246492 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sun, 7 Oct 2018 13:33:42 +0100 Subject: common scheduling methods have been moved to a separate abstract class. --- bot/cogs/moderation.py | 50 ++++++++------------------------------- bot/cogs/reminders.py | 62 +++++++++++-------------------------------------- bot/utils/scheduling.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 89 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 4a0e4c0f4..72efee9a5 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,7 +1,6 @@ import asyncio import logging import textwrap -from typing import Dict from aiohttp import ClientError from discord import Colour, Embed, Guild, Member, Object, User @@ -13,7 +12,7 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs from bot.converters import InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator -from bot.utils.scheduling import create_task +from bot.utils.scheduling import Scheduler from bot.utils.time import parse_rfc1123, wait_until log = logging.getLogger(__name__) @@ -21,7 +20,7 @@ log = logging.getLogger(__name__) MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator -class Moderation: +class Moderation(Scheduler): """ Rowboat replacement moderation tools. """ @@ -29,8 +28,8 @@ class Moderation: def __init__(self, bot: Bot): self.bot = bot self.headers = {"X-API-KEY": Keys.site_api} - self.expiration_tasks: Dict[str, asyncio.Task] = {} self._muted_role = Object(constants.Roles.muted) + super().__init__() @property def mod_log(self) -> ModLog: @@ -47,7 +46,7 @@ class Moderation: loop = asyncio.get_event_loop() for infraction_object in infraction_list: if infraction_object["expires_at"] is not None: - self.schedule_expiration(loop, infraction_object) + self.schedule_task(loop, infraction_object["id"], infraction_object) # region: Permanent infractions @@ -291,7 +290,7 @@ class Moderation: infraction_expiration = infraction_object["expires_at"] loop = asyncio.get_event_loop() - self.schedule_expiration(loop, infraction_object) + self.schedule_task(loop, infraction_object["id"], infraction_object) if reason is None: result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." @@ -356,7 +355,7 @@ class Moderation: infraction_expiration = infraction_object["expires_at"] loop = asyncio.get_event_loop() - self.schedule_expiration(loop, infraction_object) + self.schedule_task(loop, infraction_object["id"], infraction_object) if reason is None: result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." @@ -536,9 +535,9 @@ class Moderation: infraction_object = response_object["infraction"] # Re-schedule - self.cancel_expiration(infraction_id) + self.cancel_task(infraction_id) loop = asyncio.get_event_loop() - self.schedule_expiration(loop, infraction_object) + self.schedule_task(loop, infraction_object["id"], infraction_object) if duration is None: await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") @@ -744,36 +743,7 @@ class Moderation: max_size=1000 ) - def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): - """ - Schedules a task to expire a temporary infraction. - :param loop: the asyncio event loop - :param infraction_object: the infraction object to expire at the end of the task - """ - - infraction_id = infraction_object["id"] - if infraction_id in self.expiration_tasks: - return - - task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object)) - - self.expiration_tasks[infraction_id] = task - - def cancel_expiration(self, infraction_id: str): - """ - Un-schedules a task set to expire a temporary infraction. - :param infraction_id: the ID of the infraction in question - """ - - task = self.expiration_tasks.get(infraction_id) - if task is None: - log.warning(f"Failed to unschedule {infraction_id}: no task found.") - return - task.cancel() - log.debug(f"Unscheduled {infraction_id}.") - del self.expiration_tasks[infraction_id] - - async def _scheduled_expiration(self, infraction_object): + async def _scheduled_task(self, infraction_object: dict): """ A co-routine which marks an infraction as expired after the delay from the time of scheduling to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website, @@ -790,7 +760,7 @@ class Moderation: log.debug(f"Marking infraction {infraction_id} as inactive (expired).") await self._deactivate_infraction(infraction_object) - self.cancel_expiration(infraction_object["id"]) + self.cancel_task(infraction_object["id"]) async def _deactivate_infraction(self, infraction_object): """ diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 98d7942b3..f6ed111dc 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,7 +14,7 @@ from bot.constants import ( POSITIVE_REPLIES, Roles, URLs ) from bot.pagination import LinePaginator -from bot.utils.scheduling import create_task +from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta, parse_rfc1123, wait_until log = logging.getLogger(__name__) @@ -24,16 +24,12 @@ WHITELISTED_CHANNELS = (Channels.bot,) MAXIMUM_REMINDERS = 5 -# The scheduling parts of this cog are pretty much directly copied -# from the moderation cog. I'll be working on making it more -# webscale:tm: as soon as possible, because this is a mess :D -class Reminders: +class Reminders(Scheduler): def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-Key": Keys.site_api} - self.reminder_tasks = {} + super().__init__() async def on_ready(self): # Get all the current reminders for re-scheduling @@ -57,7 +53,7 @@ class Reminders: await self.send_reminder(reminder, late) else: - self.schedule_reminder(loop, reminder) + self.schedule_task(loop, reminder["id"], reminder) @staticmethod async def _send_confirmation(ctx: Context, response: dict, on_success: str): @@ -87,24 +83,7 @@ class Reminders: await ctx.send(embed=embed) return failed - def schedule_reminder(self, loop: asyncio.AbstractEventLoop, reminder): - """ - Schedule a reminder from the bot at the requested time. - - :param loop: the asyncio event loop - :param reminder: the data of the reminder. - """ - - # Avoid duplicate schedules, just in case. - reminder_id = reminder["id"] - if reminder_id in self.reminder_tasks: - return - - # Make a scheduled task and add it to the list - task: asyncio.Task = create_task(loop, self._scheduled_reminder(reminder)) - self.reminder_tasks[reminder_id] = task - - async def _scheduled_reminder(self, reminder): + async def _scheduled_task(self, reminder: dict): """ A coroutine which sends the reminder once the time is reached. @@ -120,27 +99,10 @@ class Reminders: await self.send_reminder(reminder) log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder) + await self._delete_reminder(reminder_id) # Now we can begone with it from our schedule list. - self.cancel_reminder(reminder_id) - - def cancel_reminder(self, reminder_id: str): - """ - Un-schedules a task to send a reminder. - - :param reminder_id: the ID of the reminder in question - """ - - task = self.reminder_tasks.get(reminder_id) - - if task is None: - log.warning(f"Failed to unschedule {reminder_id}: no task found.") - return - - task.cancel() - log.debug(f"Unscheduled {reminder_id}.") - del self.reminder_tasks[reminder_id] + self.cancel_task(reminder_id) async def _delete_reminder(self, reminder_id: str): """ @@ -163,7 +125,7 @@ class Reminders: ) # Now we can remove it from the schedule list - self.cancel_reminder(reminder_id) + self.cancel_task(reminder_id) async def _reschedule_reminder(self, reminder): """ @@ -174,8 +136,8 @@ class Reminders: loop = asyncio.get_event_loop() - self.cancel_reminder(reminder["id"]) - self.schedule_reminder(loop, reminder) + self.cancel_task(reminder["id"]) + self.schedule_task(loop, reminder["id"], reminder) async def send_reminder(self, reminder, late: relativedelta = None): """ @@ -291,7 +253,9 @@ class Reminders: # If it worked, schedule the reminder. if not failed: loop = asyncio.get_event_loop() - self.schedule_reminder(loop=loop, reminder=response_data["reminder"]) + reminder = response_data["reminder"] + + self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context): diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f9b844046..ded6401b0 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,5 +1,60 @@ import asyncio import contextlib +import logging +from abc import ABC, abstractmethod +from typing import Dict + +log = logging.getLogger(__name__) + + +class Scheduler(ABC): + + def __init__(self): + + self.cog_name = self.__class__.__name__ # keep track of the child cog's name so the logs are clear. + self.scheduled_tasks: Dict[str, asyncio.Task] = {} + + @abstractmethod + async def _scheduled_task(self, task_object: dict): + """ + A coroutine which handles the scheduling. This is added to the scheduled tasks, + and should wait the task duration, execute the desired code, and clean up the task. + For example, in Reminders this will wait for the reminder duration, send the reminder, + then make a site API request to delete the reminder from the database. + + :param task_object: + """ + + def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict): + """ + Schedules a task. + :param loop: the asyncio event loop + :param task_id: the ID of the task. + :param task_data: the data of the task, passed to `Scheduler._scheduled_expiration`. + """ + + if task_id in self.scheduled_tasks: + return + + task: asyncio.Task = create_task(loop, self._scheduled_task(task_data)) + + self.scheduled_tasks[task_id] = task + + def cancel_task(self, task_id: str): + """ + Un-schedules a task. + :param task_id: the ID of the infraction in question + """ + + task = self.scheduled_tasks.get(task_id) + + if task is None: + log.warning(f"{self.cog_name}: Failed to unschedule {task_id} (no task found).") + return + + task.cancel() + log.debug(f"{self.cog_name}: Unscheduled {task_id}.") + del self.scheduled_tasks[task_id] def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): -- cgit v1.2.3 From e61a2acae1e61f03af9ac9d910a56cdfb04fbe99 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sun, 7 Oct 2018 21:10:40 +0000 Subject: All command groups now invoke the help command. --- bot/cogs/bigbrother.py | 4 +++- bot/cogs/cogs.py | 4 +++- bot/cogs/defcon.py | 2 +- bot/cogs/deployment.py | 4 +++- bot/cogs/eval.py | 2 ++ bot/cogs/moderation.py | 8 ++++++-- bot/cogs/off_topic_names.py | 4 +++- bot/cogs/snakes.py | 4 +++- 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 9ea8efdb0..3f30eb0e9 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -79,11 +79,13 @@ class BigBrother: await channel.send(relay_content) - @group(name='bigbrother', aliases=('bb',)) + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def bigbrother_group(self, ctx: Context): """Monitor users, NSA-style.""" + await ctx.invoke(self.bot.get_command("help"), "bigbrother") + @bigbrother_group.command(name='watched', aliases=('all',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watched_command(self, ctx: Context, from_cache: bool = True): diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 780850b5a..f090984dd 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -36,11 +36,13 @@ class Cogs: # Allow reverse lookups by reversing the pairs self.cogs.update({v: k for k, v in self.cogs.items()}) - @group(name='cogs', aliases=('c',)) + @group(name='cogs', aliases=('c',), invoke_without_command=True) @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops) async def cogs_group(self, ctx: Context): """Load, unload, reload, and list active cogs.""" + await ctx.invoke(self.bot.get_command("help"), "cogs") + @cogs_group.command(name='load', aliases=('l',)) @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops) async def load_command(self, ctx: Context, cog: str): diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index beb05ba46..c432d377c 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -102,7 +102,7 @@ class Defcon: async def defcon_group(self, ctx: Context): """Check the DEFCON status or run a subcommand.""" - await ctx.invoke(self.status_command) + await ctx.invoke(self.bot.get_command("help"), "defcon") @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py index 790af582b..bc9dbf5ab 100644 --- a/bot/cogs/deployment.py +++ b/bot/cogs/deployment.py @@ -17,11 +17,13 @@ class Deployment: def __init__(self, bot: Bot): self.bot = bot - @group(name='redeploy') + @group(name='redeploy', invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def redeploy_group(self, ctx: Context): """Redeploy the bot or the site.""" + await ctx.invoke(self.bot.get_command("help"), "redeploy") + @redeploy_group.command(name='bot') @with_role(Roles.admin, Roles.owner, Roles.devops) async def bot_command(self, ctx: Context): diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 30e528efa..faecdf145 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,6 +178,8 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx): """Internal commands. Top secret!""" + await ctx.invoke(self.bot.get_command("help"), "internal") + @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin, Roles.owner) async def eval(self, ctx, *, code: str): diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 4a0e4c0f4..588962e29 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -489,15 +489,19 @@ class Moderation: # region: Edit infraction commands @with_role(*MODERATION_ROLES) - @group(name='infraction', aliases=('infr', 'infractions', 'inf')) + @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) async def infraction_group(self, ctx: Context): """Infraction manipulation commands.""" + await ctx.invoke(self.bot.get_command("help"), "infraction") + @with_role(*MODERATION_ROLES) - @infraction_group.group(name='edit') + @infraction_group.group(name='edit', invoke_without_command=True) async def infraction_edit_group(self, ctx: Context): """Infraction editing commands.""" + await ctx.invoke(self.bot.get_command("help"), "infraction", "edit") + @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="duration") async def edit_duration(self, ctx, infraction_id: str, duration: str): diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index ac2e1269c..25b8a48b8 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -86,11 +86,13 @@ class OffTopicNames: coro = update_names(self.bot, self.headers) self.updater_task = await self.bot.loop.create_task(coro) - @group(name='otname', aliases=('otnames', 'otn')) + @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def otname_group(self, ctx): """Add or list items from the off-topic channel name rotation.""" + await ctx.invoke(self.bot.get_command("help"), "otname") + @otname_group.command(name='add', aliases=('a',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def add_command(self, ctx, name: OffTopicName): diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py index f83f8e354..d74380259 100644 --- a/bot/cogs/snakes.py +++ b/bot/cogs/snakes.py @@ -462,10 +462,12 @@ class Snakes: # endregion # region: Commands - @group(name='snakes', aliases=('snake',)) + @group(name='snakes', aliases=('snake',), invoke_without_command=True) async def snakes_group(self, ctx: Context): """Commands from our first code jam.""" + await ctx.invoke(self.bot.get_command("help"), "snake") + @bot_has_permissions(manage_messages=True) @snakes_group.command(name='antidote') @locked() -- cgit v1.2.3 From 129dbe73cd9e07e3dacf959a0ed52dc15b4eb8e3 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Tue, 9 Oct 2018 13:32:16 +0000 Subject: Bot Codeblock message fixes --- bot/cogs/bot.py | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index acbc29f98..168916a64 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -3,7 +3,7 @@ import logging import re import time -from discord import Embed, Message, RawReactionActionEvent +from discord import Embed, Message, RawMessageUpdateEvent, RawReactionActionEvent from discord.ext.commands import Bot, Context, command, group from dulwich.repo import Repo @@ -357,25 +357,43 @@ class Bot: f"The message that was posted was:\n\n{msg.content}\n\n" ) - async def on_message_edit(self, before: Message, after: Message): - has_fixed_codeblock = ( - # Checks if the original message was previously called out by the bot - before.id in self.codeblock_message_ids - # Checks to see if the user has corrected their codeblock - and self.codeblock_stripping(after.content, self.has_bad_ticks(after)) is None - ) + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(payload.data.get("channel_id")) + user_message = await channel.get_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock: - bot_message = await after.channel.get_message(self.codeblock_message_ids[after.id]) + bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id]) await bot_message.delete() - del self.codeblock_message_ids[after.id] + del self.codeblock_message_ids[payload.message_id] async def on_raw_reaction_add(self, payload: RawReactionActionEvent): # Ignores reactions added by the bot or added to non-codeblock correction embed messages # Also ignores the reaction if the user can't be loaded - user = self.bot.get_user(payload.user_id) - if user is None: + # Retrieve Member object instead of user in order to compare roles later + # Try except used to catch instances where guild_id not in payload. + try: + member = self.bot.get_guild(payload.guild_id).get_member(payload.user_id) + except AttributeError: + return + + if member is None: return - if user.bot or payload.message_id not in self.codeblock_message_ids.values(): + if member.bot or payload.message_id not in self.codeblock_message_ids.values(): return # Finds the appropriate bot message/ user message pair and assigns them to variables @@ -387,13 +405,13 @@ class Bot: break # If the reaction was clicked on by the author of the user message, deletes the bot message - if user.id == user_message.author.id: + if member.id == user_message.author.id: await bot_message.delete() del self.codeblock_message_ids[user_message_id] return # If the reaction was clicked by staff (helper or higher), deletes the bot message - for role in user.roles: + for role in member.roles: if role.id in (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers): await bot_message.delete() del self.codeblock_message_ids[user_message_id] -- cgit v1.2.3 From db1efddda4e042587bc9c91408bc5ecf590ed107 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Tue, 9 Oct 2018 20:21:48 +0200 Subject: Wolfram Cog - Merge Request 56, by Chibli --- bot/__main__.py | 1 + bot/cogs/wolfram.py | 289 ++++++++++++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 9 ++ bot/pagination.py | 185 +++++++++++++++++++++++++++++++-- config-default.yml | 7 ++ 5 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 bot/cogs/wolfram.py diff --git a/bot/__main__.py b/bot/__main__.py index 602846ded..3059b3ed0 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -72,6 +72,7 @@ bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") +bot.load_extension("bot.cogs.wolfram") if has_rmq: bot.load_extension("bot.cogs.rmq") diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py new file mode 100644 index 000000000..aabd83f9f --- /dev/null +++ b/bot/cogs/wolfram.py @@ -0,0 +1,289 @@ +import logging +from io import BytesIO +from typing import List, Optional, Tuple +from urllib import parse + +import discord +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Context, check, group + +from bot.constants import Colours, Roles, Wolfram +from bot.pagination import ImagePaginator + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +COOLDOWN_IGNORERS = Roles.moderator, Roles.owner, Roles.admin, Roles.helpers +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int=Colours.soft_red, + footer: str=None, + img_url: str=None, + f: discord.File = None +) -> None: + """ + Generates an embed with wolfram as the author, with message_txt as description, + adds custom colour if specified, a footer and image (could be a file with f param) and sends + the embed through ctx + :param ctx: Context + :param message_txt: str - Message to be sent + :param colour: int - Default: Colours.soft_red - Colour of embed + :param footer: str - Default: None - Adds a footer to the embed + :param img_url:str - Default: None - Adds an image to the embed + :param f: discord.File - Default: None - Add a file to the msg, often attached as image to embed + """ + + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> check: + """ + Custom cooldown mapping that applies a specific requests per day to users. + Staff is ignored by the user cooldown, however the cooldown implements a + total amount of uses per day for the entire guild. (Configurable in configs) + + :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown + :return: check + """ + + async def predicate(ctx: Context) -> bool: + user_bucket = usercd.get_bucket(ctx.message) + + if ctx.author.top_role.id not in ignore: + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {int(user_rate)}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + return check(predicate) + + +async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: + # Give feedback that the bot is working. + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if result["error"]: + message = "Something went wrong internally with your request, please notify staff!" + log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram: + """ + Commands for interacting with the Wolfram|Alpha API. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @custom_cooldown(*COOLDOWN_IGNORERS) + async def wolfram_command(self, ctx: Context, *, query: str) -> None: + """ + Requests all answers on a single image, + sends an image of all related pods + + :param ctx: Context + :param query: str - string request to api + """ + + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*COOLDOWN_IGNORERS) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + + :param ctx: Context + :param query: str - string request to api + """ + + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*COOLDOWN_IGNORERS) + async def wolfram_cut_command(self, ctx, *, query: str) -> None: + """ + Requests a drawn image of given query + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + + :param ctx: Context + :param query: str - string request to api + """ + + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @custom_cooldown(*COOLDOWN_IGNORERS) + async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: + """ + Requests an answer to a simple question + Responds in plaintext + + :param ctx: Context + :param query: str - string request to api + """ + + url_str = parse.urlencode({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + + elif status == 400: + message = "No input found" + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: commands.Bot) -> None: + bot.add_cog(Wolfram(bot)) + log.info("Cog loaded: Wolfram") diff --git a/bot/constants.py b/bot/constants.py index 2433d15ef..145dc4700 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -224,6 +224,7 @@ class Colours(metaclass=YAMLGetter): soft_red: int soft_green: int + soft_orange: int class Emojis(metaclass=YAMLGetter): @@ -424,6 +425,14 @@ class Reddit(metaclass=YAMLGetter): subreddits: list +class Wolfram(metaclass=YAMLGetter): + section = "wolfram" + + user_limit_day: int + guild_limit_day: int + key: str + + class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' diff --git a/bot/pagination.py b/bot/pagination.py index 9319a5b60..cfd6287f7 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,16 +1,16 @@ import asyncio import logging -from typing import Iterable, Optional +from typing import Iterable, List, Optional, Tuple from discord import Embed, Member, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator -LEFT_EMOJI = "\u2B05" -RIGHT_EMOJI = "\u27A1" -DELETE_EMOJI = "\u274c" -FIRST_EMOJI = "\u23EE" -LAST_EMOJI = "\u23ED" +FIRST_EMOJI = "\u23EE" # [:track_previous:] +LEFT_EMOJI = "\u2B05" # [:arrow_left:] +RIGHT_EMOJI = "\u27A1" # [:arrow_right:] +LAST_EMOJI = "\u23ED" # [:track_next:] +DELETE_EMOJI = "\u274c" # [:x:] PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] @@ -275,3 +275,176 @@ class LinePaginator(Paginator): log.debug("Ending pagination and removing all reactions...") await message.clear_reactions() + + +class ImagePaginator(Paginator): + """ + Helper class that paginates images for embeds in messages. + Close resemblance to LinePaginator, except focuses on images over text. + + Refer to ImagePaginator.paginate for documentation on how to use. + """ + + def __init__(self, prefix="", suffix=""): + super().__init__(prefix, suffix) + self._current_page = [prefix] + self.images = [] + self._pages = [] + + def add_line(self, line: str='', *, empty: bool=False) -> None: + """ + Adds a line to each page, usually just 1 line in this context + :param line: str to be page content / title + :param empty: if there should be new lines between entries + """ + + if line: + self._count = len(line) + else: + self._count = 0 + self._current_page.append(line) + self.close_page() + + def add_image(self, image: str=None) -> None: + """ + Adds an image to a page + :param image: image url to be appended + """ + + self.images.append(image) + + @classmethod + async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, + prefix: str="", suffix: str="", timeout: int=300): + """ + Use a paginator and set of reactions to provide + pagination over a set of title/image pairs.The reactions are + used to switch page, or to finish with pagination. + + When used, this will send a message using `ctx.send()` and + apply a set of reactions to it. These reactions may + be used to change page, or to remove pagination from the message. + + Note: Pagination will be removed automatically + if no reaction is added for five minutes (300 seconds). + + >>> embed = Embed() + >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) + >>> await ImagePaginator.paginate(pages, ctx, embed) + + Parameters + ----------- + :param pages: An iterable of tuples with title for page, and img url + :param ctx: ctx for message + :param embed: base embed to modify + :param prefix: prefix of message + :param suffix: suffix of message + :param timeout: timeout for when reactions get auto-removed + """ + + def check_event(reaction_: Reaction, member: Member) -> bool: + """ + Checks each reaction added, if it matches our conditions pass the wait_for + :param reaction_: reaction added + :param member: reaction added by member + """ + + return all(( + # Reaction is on the same message sent + reaction_.message.id == message.id, + # The reaction is part of the navigation menu + reaction_.emoji in PAGINATION_EMOJI, + # The reactor is not a bot + not member.bot + )) + + paginator = cls(prefix=prefix, suffix=suffix) + current_page = 0 + + for text, image_url in pages: + paginator.add_line(text) + paginator.add_image(image_url) + + embed.description = paginator.pages[current_page] + image = paginator.images[current_page] + + if image: + embed.set_image(url=image) + + if len(paginator.pages) <= 1: + return await ctx.send(embed=embed) + + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + message = await ctx.send(embed=embed) + + for emoji in PAGINATION_EMOJI: + await message.add_reaction(emoji) + + while True: + # Start waiting for reactions + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event) + except asyncio.TimeoutError: + log.debug("Timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + # Deletes the users reaction + await message.remove_reaction(reaction.emoji, user) + + # Delete reaction press - [:x:] + if reaction.emoji == DELETE_EMOJI: + log.debug("Got delete reaction") + break + + # First reaction press - [:track_previous:] + if reaction.emoji == FIRST_EMOJI: + if current_page == 0: + log.debug("Got first page reaction, but we're on the first page - ignoring") + continue + + current_page = 0 + reaction_type = "first" + + # Last reaction press - [:track_next:] + if reaction.emoji == LAST_EMOJI: + if current_page >= len(paginator.pages) - 1: + log.debug("Got last page reaction, but we're on the last page - ignoring") + continue + + current_page = len(paginator.pages - 1) + reaction_type = "last" + + # Previous reaction press - [:arrow_left: ] + if reaction.emoji == LEFT_EMOJI: + if current_page <= 0: + log.debug("Got previous page reaction, but we're on the first page - ignoring") + continue + + current_page -= 1 + reaction_type = "previous" + + # Next reaction press - [:arrow_right:] + if reaction.emoji == RIGHT_EMOJI: + if current_page >= len(paginator.pages) - 1: + log.debug("Got next page reaction, but we're on the last page - ignoring") + continue + + current_page += 1 + reaction_type = "next" + + # Magic happens here, after page and reaction_type is set + embed.description = "" + await message.edit(embed=embed) + embed.description = paginator.pages[current_page] + + image = paginator.images[current_page] + if image: + embed.set_image(url=image) + + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") + + await message.edit(embed=embed) + + log.debug("Ending pagination and removing all reactions...") + await message.clear_reactions() diff --git a/config-default.yml b/config-default.yml index 7130eb540..15f1a143a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -15,6 +15,7 @@ style: colours: soft_red: 0xcd6d6d soft_green: 0x68c290 + soft_orange: 0xf9cb54 emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" @@ -306,3 +307,9 @@ reddit: request_delay: 60 subreddits: - 'r/Python' + +wolfram: + # Max requests per day. + user_limit_day: 10 + guild_limit_day: 67 + key: !ENV "WOLFRAM_API_KEY" -- cgit v1.2.3 From 130ce32cbd7fb30cb0defe50614b46eb0e3a214d Mon Sep 17 00:00:00 2001 From: scragly Date: Sat, 20 Oct 2018 17:36:26 +1000 Subject: Change BYPASS_ROLES check from top_role to all roles. Addresses Issue #72 --- bot/cogs/snekbox.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index fb9164194..2e52b8d1b 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -60,10 +60,11 @@ async def channel_is_whitelisted_or_author_can_bypass(ctx: Context): or the channel is a whitelisted channel. """ - if ctx.channel.id not in WHITELISTED_CHANNELS and ctx.author.top_role.id not in BYPASS_ROLES: - raise MissingPermissions("You are not allowed to do that here.") - - return True + if ctx.channel.id in WHITELISTED_CHANNELS: + return True + if any(r.id in BYPASS_ROLES for r in ctx.author.roles): + return True + raise MissingPermissions("You are not allowed to do that here.") class Snekbox: -- cgit v1.2.3 From a9f993afd91cc40b75183c4af4aa8d0b4503ee5c Mon Sep 17 00:00:00 2001 From: Thomas Petersson Date: Mon, 22 Oct 2018 08:26:39 +0000 Subject: Chibli | Cog for command aliases --- bot/__main__.py | 1 + bot/cogs/alias.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/tags.py | 57 +------------------- bot/converters.py | 59 ++++++++++++++++++++- 4 files changed, 214 insertions(+), 56 deletions(-) create mode 100644 bot/cogs/alias.py diff --git a/bot/__main__.py b/bot/__main__.py index 3059b3ed0..f74b3545c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -56,6 +56,7 @@ if not DEBUG_MODE: bot.load_extension("bot.cogs.verification") # Feature cogs +bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py new file mode 100644 index 000000000..940bdaa43 --- /dev/null +++ b/bot/cogs/alias.py @@ -0,0 +1,153 @@ +import logging + +from discord import TextChannel, User +from discord.ext.commands import ( + Context, clean_content, command, group +) + +from bot.converters import TagNameConverter + +log = logging.getLogger(__name__) + + +class Alias: + """ + Aliases for more used commands + """ + + def __init__(self, bot): + self.bot = bot + + async def invoke(self, ctx, cmd_name, *args, **kwargs): + """ + Invokes a command with args and kwargs. + Fail early through `command.can_run`, and logs warnings. + + :param ctx: Context instance for command call + :param cmd_name: Name of command/subcommand to be invoked + :param args: args to be passed to the command + :param kwargs: kwargs to be passed to the command + :return: None + """ + + log.debug(f"{cmd_name} was invoked through an alias") + cmd = self.bot.get_command(cmd_name) + if not cmd: + return log.warning(f'Did not find command "{cmd_name}" to invoke.') + elif not await cmd.can_run(ctx): + return log.warning( + f'{str(ctx.author)} tried to run the command "{cmd_name}"' + ) + + await ctx.invoke(cmd, *args, **kwargs) + + @command(name="resources", aliases=("resource",), hidden=True) + async def site_resources_alias(self, ctx): + """ + Alias for invoking site resources. + """ + + await self.invoke(ctx, "site resources") + + @command(name="watch", hidden=True) + async def bigbrother_watch_alias( + self, ctx, user: User, channel: TextChannel = None + ): + """ + Alias for invoking bigbrother watch user [text_channel]. + """ + + await self.invoke(ctx, "bigbrother watch", user, channel) + + @command(name="unwatch", hidden=True) + async def bigbrother_unwatch_alias(self, ctx, user: User): + """ + Alias for invoking bigbrother unwatch user. + + user: discord.User - A user instance to unwatch + """ + + await self.invoke(ctx, "bigbrother unwatch", user) + + @command(name="home", hidden=True) + async def site_home_alias(self, ctx): + """ + Alias for invoking site home. + """ + + await self.invoke(ctx, "site home") + + @command(name="faq", hidden=True) + async def site_faq_alias(self, ctx): + """ + Alias for invoking site faq. + """ + + await self.invoke(ctx, "site faq") + + @command(name="reload", hidden=True) + async def reload_cog_alias(self, ctx, *, cog_name: str): + """ + Alias for invoking cogs reload cog_name. + + cog_name: str - name of the cog to be reloaded. + """ + + await self.invoke(ctx, "cogs reload", cog_name) + + @command(name="defon", hidden=True) + async def defcon_enable_alias(self, ctx): + """ + Alias for invoking defcon enable. + """ + + await self.invoke(ctx, "defcon enable") + + @command(name="defoff", hidden=True) + async def defcon_disable_alias(self, ctx): + """ + Alias for invoking defcon disable. + """ + + await self.invoke(ctx, "defcon disable") + + @group(name="get", + aliases=("show", "g"), + hidden=True, + invoke_without_command=True) + async def get_group_alias(self, ctx): + """ + Group for reverse aliases for commands like `tags get`, + allowing for `get tags` or `get docs`. + """ + + pass + + @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) + async def get_tags_command_alias( + self, ctx: Context, *, tag_name: TagNameConverter=None + ): + """ + Alias for invoking tags get [tag_name]. + + tag_name: str - tag to be viewed. + """ + + await self.invoke(ctx, "tags get", tag_name) + + @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) + async def get_docs_command_alias( + self, ctx: Context, symbol: clean_content = None + ): + """ + Alias for invoking docs get [symbol]. + + symbol: str - name of doc to be viewed. + """ + + await self.invoke(ctx, "docs get", symbol) + + +def setup(bot): + bot.add_cog(Alias(bot)) + log.info("Cog loaded: Alias") diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index e6f9ecd89..cdc2861b1 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -6,13 +6,13 @@ from typing import Optional from discord import Colour, Embed from discord.ext.commands import ( BadArgument, Bot, - Context, Converter, group + Context, group ) from bot.constants import ( Channels, Cooldowns, ERROR_REPLIES, Keys, Roles, URLs ) -from bot.converters import ValidURL +from bot.converters import TagContentConverter, TagNameConverter, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -26,59 +26,6 @@ TEST_CHANNELS = ( ) -class TagNameConverter(Converter): - @staticmethod - async def convert(ctx: Context, tag_name: str): - def is_number(value): - try: - float(value) - except ValueError: - return False - return True - - tag_name = tag_name.lower().strip() - - # The tag name has at least one invalid character. - if ascii(tag_name)[1:-1] != tag_name: - log.warning(f"{ctx.author} tried to put an invalid character in a tag name. " - "Rejecting the request.") - raise BadArgument("Don't be ridiculous, you can't use that character!") - - # The tag name is either empty, or consists of nothing but whitespace. - elif not tag_name: - log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. " - "Rejecting the request.") - raise BadArgument("Tag names should not be empty, or filled with whitespace.") - - # The tag name is a number of some kind, we don't allow that. - elif is_number(tag_name): - log.warning(f"{ctx.author} tried to create a tag with a digit as its name. " - "Rejecting the request.") - raise BadArgument("Tag names can't be numbers.") - - # The tag name is longer than 127 characters. - elif len(tag_name) > 127: - log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " - "Rejecting the request.") - raise BadArgument("Are you insane? That's way too long!") - - return tag_name - - -class TagContentConverter(Converter): - @staticmethod - async def convert(ctx: Context, tag_content: str): - tag_content = tag_content.strip() - - # The tag contents should not be empty, or filled with whitespace. - if not tag_content: - log.warning(f"{ctx.author} tried to create a tag containing only whitespace. " - "Rejecting the request.") - raise BadArgument("Tag contents should not be empty, or filled with whitespace.") - - return tag_content - - class Tags: """ Save new tags and fetch existing tags. diff --git a/bot/converters.py b/bot/converters.py index c8bc75715..069e841f9 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,16 +1,20 @@ +import logging import random import socket from ssl import CertificateError import discord from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector -from discord.ext.commands import BadArgument, Converter +from discord.ext.commands import BadArgument, Context, Converter from fuzzywuzzy import fuzz from bot.constants import DEBUG_MODE, Keys, URLs from bot.utils import disambiguate +log = logging.getLogger(__name__) + + class Snake(Converter): snakes = None special_cases = None @@ -197,3 +201,56 @@ class Subreddit(Converter): ) return sub + + +class TagNameConverter(Converter): + @staticmethod + async def convert(ctx: Context, tag_name: str): + def is_number(value): + try: + float(value) + except ValueError: + return False + return True + + tag_name = tag_name.lower().strip() + + # The tag name has at least one invalid character. + if ascii(tag_name)[1:-1] != tag_name: + log.warning(f"{ctx.author} tried to put an invalid character in a tag name. " + "Rejecting the request.") + raise BadArgument("Don't be ridiculous, you can't use that character!") + + # The tag name is either empty, or consists of nothing but whitespace. + elif not tag_name: + log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. " + "Rejecting the request.") + raise BadArgument("Tag names should not be empty, or filled with whitespace.") + + # The tag name is a number of some kind, we don't allow that. + elif is_number(tag_name): + log.warning(f"{ctx.author} tried to create a tag with a digit as its name. " + "Rejecting the request.") + raise BadArgument("Tag names can't be numbers.") + + # The tag name is longer than 127 characters. + elif len(tag_name) > 127: + log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. " + "Rejecting the request.") + raise BadArgument("Are you insane? That's way too long!") + + return tag_name + + +class TagContentConverter(Converter): + @staticmethod + async def convert(ctx: Context, tag_content: str): + tag_content = tag_content.strip() + + # The tag contents should not be empty, or filled with whitespace. + if not tag_content: + log.warning(f"{ctx.author} tried to create a tag containing only whitespace. " + "Rejecting the request.") + raise BadArgument("Tag contents should not be empty, or filled with whitespace.") + + return tag_content -- cgit v1.2.3 From 3fb2a9310591d67a66e8731708caee83dd1d65de Mon Sep 17 00:00:00 2001 From: scragly Date: Tue, 23 Oct 2018 15:43:31 +1000 Subject: GitIgnore: VSCode --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4321d9324..be4f43c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,9 @@ ENV/ # PyCharm .idea/ +# VSCode +.vscode/ + # Vagrant .vagrant -- cgit v1.2.3 From 39f2a3a8808865a06224acc979840b6ad4c0ee01 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sat, 27 Oct 2018 14:23:37 +0100 Subject: Added site command and alias for rules. --- bot/cogs/alias.py | 8 ++++++++ bot/cogs/site.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 940bdaa43..7b342a2d0 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -85,6 +85,14 @@ class Alias: await self.invoke(ctx, "site faq") + @command(name="rules", hidden=True) + async def site_rules_alias(self, ctx): + """ + Alias for invoking site rules. + """ + + await self.invoke(ctx, "site rules") + @command(name="reload", hidden=True) async def reload_cog_alias(self, ctx, *, cog_name: str): """ diff --git a/bot/cogs/site.py b/bot/cogs/site.py index e5fd645fb..442e80cd2 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -92,6 +92,22 @@ class Site: await ctx.send(embed=embed) + @site_group.command(name="rules") + async def site_rules(self, ctx: Context): + """Info about the server's rules.""" + + url = f"{URLs.site_schema}{URLs.site}/about/rules" + + embed = Embed(title="Rules") + embed.set_footer(text=url) + embed.colour = Colour.blurple() + embed.description = ( + f"The rules and guidelines that apply to this community can be found on our [rules page]({url}). " + "We expect all members of the community to have read and understood these." + ) + + await ctx.send(embed=embed) + def setup(bot): bot.add_cog(Site(bot)) -- cgit v1.2.3 From 8aa586bba43fbcfdf2633065c83f5f5123113776 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 29 Oct 2018 13:39:15 -0500 Subject: Correcting a logic error in the edited message deletion thingy. Also cleaned up the has_bad_ticks() function a bit. Signed-off-by: Daniel Brown --- bot/cogs/bot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 168916a64..846783e08 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -236,8 +236,7 @@ class Bot: "\u3003\u3003\u3003" ] - has_bad_ticks = msg.content[:3] in not_backticks - return has_bad_ticks + return msg.content[:3] in not_backticks async def on_message(self, msg: Message): """ @@ -372,11 +371,11 @@ class Bot: channel = self.bot.get_channel(payload.data.get("channel_id")) user_message = await channel.get_message(payload.message_id) - # Checks to see if the user has corrected their codeblock + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock: + if has_fixed_codeblock is None: bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id]) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] -- cgit v1.2.3 From 7680c698f23e012d76e772a3e4f21e1aa22ab224 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Tue, 30 Oct 2018 23:01:16 +0000 Subject: Fixed eval to allow ```python in messages. --- bot/cogs/eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index faecdf145..421fa5b53 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -185,7 +185,7 @@ async def func(): # (None,) -> Any async def eval(self, ctx, *, code: str): """ Run eval in a REPL-like format. """ code = code.strip("`") - if code.startswith("py\n"): + if re.match('py(thon)?\n', code): code = "\n".join(code.split("\n")[1:]) if not re.search( # Check if it's an expression -- cgit v1.2.3 From 73b64c7f875ee16d1187522840b3ae54619e7322 Mon Sep 17 00:00:00 2001 From: sco1 Date: Sat, 3 Nov 2018 12:51:52 -0400 Subject: Add "superstarify" replacement for "hiphopify" --- bot/__main__.py | 2 +- bot/cogs/superstarify.py | 195 +++++++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 2 +- config-default.yml | 2 +- 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 bot/cogs/superstarify.py diff --git a/bot/__main__.py b/bot/__main__.py index f74b3545c..ab66492bb 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -61,7 +61,7 @@ bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") -bot.load_extension("bot.cogs.hiphopify") +bot.load_extension("bot.cogs.superstarify") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py new file mode 100644 index 000000000..e1cfcc184 --- /dev/null +++ b/bot/cogs/superstarify.py @@ -0,0 +1,195 @@ +import logging +import random + +from discord import Colour, Embed, Member +from discord.errors import Forbidden +from discord.ext.commands import Bot, Context, command + +from bot.constants import ( + Channels, Keys, + NEGATIVE_REPLIES, POSITIVE_REPLIES, + Roles, URLs +) +from bot.decorators import with_role + + +log = logging.getLogger(__name__) + + +class Superstarify: + """ + A set of commands to moderate terrible nicknames. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.headers = {"X-API-KEY": Keys.site_api} + + async def on_member_update(self, before, after): + """ + This event will trigger when someone changes their name. + At this point we will look up the user in our database and check + whether they are allowed to change their names, or if they are in + superstar-prison. If they are not allowed, we will change it back. + :return: + """ + + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.debug( + f"{before.display_name} is trying to change their nickname to {after.display_name}. " + "Checking if the user is in superstar-prison..." + ) + + response = await self.bot.http_session.get( + URLs.site_superstarify_api, + headers=self.headers, + params={"user_id": str(before.id)} + ) + + response = await response.json() + + if response and response.get("end_timestamp") and not response.get("error_code"): + if after.display_name == response.get("forced_nick"): + return # Nick change was triggered by this event. Ignore. + + log.debug( + f"{after.display_name} is currently in superstar-prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit(nick=response.get("forced_nick")) + try: + await after.send( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so. " + "You will be allowed to change your nickname again at the following time:\n\n" + f"**{response.get('end_timestamp')}**." + ) + except Forbidden: + log.warning( + "The user tried to change their nickname while in superstar-prison. " + "This led to the bot trying to DM the user to let them know they cannot do that, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + @command(name='superstarify', aliases=('force_nick', 'ss')) + @with_role(Roles.admin, Roles.owner, Roles.moderator) + async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): + """ + This command will force a random superstar name (like Taylor Swift) to be the user's + nickname for a specified duration. If a forced_nick is provided, it will use that instead. + + :param ctx: Discord message context + :param ta: + If provided, this function shows data for that specific tag. + If not provided, this function shows the caller a list of all tags. + """ + + log.debug( + f"Attempting to superstarify {member.display_name} for {duration}. " + f"forced_nick is set to {forced_nick}." + ) + + embed = Embed() + embed.colour = Colour.blurple() + + params = { + "user_id": str(member.id), + "duration": duration + } + + if forced_nick: + params["forced_nick"] = forced_nick + + response = await self.bot.http_session.post( + URLs.site_superstarify_api, + headers=self.headers, + json=params + ) + + response = await response.json() + + if "error_message" in response: + log.warning( + "Encountered the following error when trying to superstarify the user:\n" + f"{response.get('error_message')}" + ) + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = response.get("error_message") + return await ctx.send(embed=embed) + + else: + forced_nick = response.get('forced_nick') + end_time = response.get("end_timestamp") + image_url = response.get("image_url") + + embed.title = "Congratulations!" + embed.description = ( + f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until \n**{end_time}**.\n\n" + "If you're confused by this, please read our " + "[official nickname policy](https://pythondiscord.com/about/rules#nickname-policy)." + ) + embed.set_image(url=image_url) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log = self.bot.get_channel(Channels.modlog) + await mod_log.send( + f":middle_finger: {member.name}#{member.discriminator} (`{member.id}`) " + f"has been superstarified by **{ctx.author.name}**. Their new nickname is `{forced_nick}`. " + f"They will not be able to change their nickname again until **{end_time}**" + ) + + # Change the nick and return the embed + log.debug("Changing the users nickname and sending the embed.") + await member.edit(nick=forced_nick) + await ctx.send(embed=embed) + + @command(name='unsuperstarify', aliases=('release_nick', 'uss')) + @with_role(Roles.admin, Roles.owner, Roles.moderator) + async def unsuperstarify(self, ctx: Context, member: Member): + """ + This command will remove the entry from our database, allowing the user + to once again change their nickname. + + :param ctx: Discord message context + :param member: The member to unsuperstarify + """ + + log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") + + embed = Embed() + embed.colour = Colour.blurple() + + response = await self.bot.http_session.delete( + URLs.site_superstarify_api, + headers=self.headers, + json={"user_id": str(member.id)} + ) + + response = await response.json() + embed.description = "User has been released from superstar-prison." + embed.title = random.choice(POSITIVE_REPLIES) + + if "error_message" in response: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = response.get("error_message") + log.warning( + f"Error encountered when trying to unsuperstarify {member.display_name}:\n" + f"{response}" + ) + + log.debug(f"{member.display_name} was successfully released from superstar-prison.") + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") diff --git a/bot/constants.py b/bot/constants.py index 145dc4700..43f03d7bf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,7 +395,7 @@ class URLs(metaclass=YAMLGetter): site_api: str site_facts_api: str site_clean_api: str - site_hiphopify_api: str + site_superstarify_api: str site_idioms_api: str site_logs_api: str site_logs_view: str diff --git a/config-default.yml b/config-default.yml index 15f1a143a..046c1ea56 100644 --- a/config-default.yml +++ b/config-default.yml @@ -218,7 +218,7 @@ urls: site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] site_facts_api: !JOIN [*SCHEMA, *API, "/bot/snake_facts"] - site_hiphopify_api: !JOIN [*SCHEMA, *API, "/bot/hiphopify"] + site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] site_idioms_api: !JOIN [*SCHEMA, *API, "/bot/snake_idioms"] site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] -- cgit v1.2.3 From 723c0ac345cf9cb7b74161938b23ecb0796a6d87 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 6 Nov 2018 20:40:40 +0100 Subject: #73: Delete eval results. --- bot/cogs/snekbox.py | 6 ++++- bot/utils/messages.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 bot/utils/messages.py diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 2e52b8d1b..184a7545d 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -12,6 +12,8 @@ from discord.ext.commands import ( from bot.cogs.rmq import RMQ from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs +from bot.utils.messages import wait_for_deletion + log = logging.getLogger(__name__) @@ -181,7 +183,9 @@ class Snekbox: else: msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" - await ctx.send(msg) + response = await ctx.send(msg) + await wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + else: await ctx.send( f"{ctx.author.mention} Your eval job has completed.\n\n```py\n[No output]\n```" diff --git a/bot/utils/messages.py b/bot/utils/messages.py new file mode 100644 index 000000000..c625beb5c --- /dev/null +++ b/bot/utils/messages.py @@ -0,0 +1,72 @@ +import asyncio +import contextlib +from typing import Sequence + +from discord import Message +from discord.abc import Snowflake + + +async def wait_for_deletion( + message: Message, + user_ids: Sequence[Snowflake], + deletion_emojis: Sequence[str]=("❌",), + timeout: float=60 * 5, + attach_emojis=True, + client=None +): + """ + Waits for up to `timeout` seconds for a reaction by + any of the specified `user_ids` to delete the message. + + Args: + message (Message): + The message that should be monitored for reactions + and possibly deleted. Must be a message sent on a + guild since access to the bot instance is required. + + user_ids (Sequence[Snowflake]): + A sequence of users that are allowed to delete + this message. + + Kwargs: + deletion_emojis (Sequence[str]): + A sequence of emojis that are considered deletion + emojis. + + timeout (float): + A positive float denoting the maximum amount of + time to wait for a deletion reaction. + + attach_emojis (bool): + Whether to attach the given `deletion_emojis` + to the message in the given `context` + + client (Optional[discord.Client]): + The client instance handling the original command. + If not given, will take the client from the guild + of the message. + """ + + if message.guild is None and client is None: + raise ValueError("Message must be sent on a guild") + + bot = client or message.guild.me + + if attach_emojis: + for emoji in deletion_emojis: + await message.add_reaction(emoji) + + def check(reaction, user): + return ( + reaction.message.id == message.id and + reaction.emoji in deletion_emojis and + user.id in user_ids + ) + + with contextlib.suppress(asyncio.TimeoutError): + await bot.wait_for( + 'reaction_add', + check=check, + timeout=timeout + ) + await message.delete() -- cgit v1.2.3 From 9123b0588fd6e8b08ba8df56342d063bebbf86a9 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 7 Nov 2018 19:14:21 +0100 Subject: Remove old syntax from `!about` command. --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 846783e08..252695027 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -60,7 +60,7 @@ class Bot: """ embed = Embed( - description="A utility bot designed just for the Python server! Try `!help()` for more info.", + description="A utility bot designed just for the Python server! Try `!help` for more info.", url="https://gitlab.com/discord-python/projects/bot" ) -- cgit v1.2.3 From 3adab46cf333266c4d433eb7088dbe7cbbf19906 Mon Sep 17 00:00:00 2001 From: scragly Date: Thu, 8 Nov 2018 06:40:10 +1000 Subject: Replace REQUIRED_ENV config tag --- bot/constants.py | 71 ++++++++++++++++++++++++++++-------------------------- config-default.yml | 7 ++++-- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 145dc4700..5d5f4609f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -18,36 +18,10 @@ from pathlib import Path from typing import Dict, List import yaml -from yaml.constructor import ConstructorError log = logging.getLogger(__name__) -def _required_env_var_constructor(loader, node): - """ - Implements a custom YAML tag for loading required environment - variables. If the environment variable is set, this function - will simply return it. Otherwise, a `CRITICAL` log message is - given and the `KeyError` is re-raised. - - Example usage in the YAML configuration: - - bot: - token: !REQUIRED_ENV 'BOT_TOKEN' - """ - - value = loader.construct_scalar(node) - - try: - return os.environ[value] - except KeyError: - log.critical( - f"Environment variable `{value}` is required, but was not set. " - "Set it in your environment or override the option using it in your `config.yml`." - ) - raise - - def _env_var_constructor(loader, node): """ Implements a custom YAML tag for loading optional environment @@ -63,8 +37,12 @@ def _env_var_constructor(loader, node): default = None - try: - # Try to construct a list from this YAML node + # Check if the node is a plain string value + if node.id == 'scalar': + value = loader.construct_scalar(node) + key = str(value) + else: + # The node value is a list value = loader.construct_sequence(node) if len(value) >= 2: @@ -74,11 +52,6 @@ def _env_var_constructor(loader, node): else: # Otherwise, we just have a key key = value[0] - except ConstructorError: - # This YAML node is a plain value rather than a list, so we just have a key - value = loader.construct_scalar(node) - - key = str(value) return os.getenv(key, default) @@ -96,7 +69,9 @@ def _join_var_constructor(loader, node): yaml.SafeLoader.add_constructor("!ENV", _env_var_constructor) yaml.SafeLoader.add_constructor("!JOIN", _join_var_constructor) -yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _required_env_var_constructor) + +# Pointing old tag to !ENV constructor to avoid breaking existing configs +yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _env_var_constructor) with open("config-default.yml", encoding="UTF-8") as f: @@ -129,6 +104,34 @@ if Path("config.yml").exists(): _recursive_update(_CONFIG_YAML, user_config) +def check_required_keys(keys): + """ + Verifies that keys that are set to be required are present in the + loaded configuration. + """ + for key_path in keys: + lookup = _CONFIG_YAML + try: + for key in key_path.split('.'): + lookup = lookup[key] + if lookup is None: + raise KeyError(key) + except KeyError: + log.critical( + f"A configuration for `{key_path}` is required, but was not found. " + "Please set it in `config.yml` or setup an environment variable and try again." + ) + raise + + +try: + required_keys = _CONFIG_YAML['config']['required_keys'] +except KeyError: + pass +else: + check_required_keys(required_keys) + + class YAMLGetter(type): """ Implements a custom metaclass used for accessing diff --git a/config-default.yml b/config-default.yml index 15f1a143a..17aa69ead 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,6 +1,6 @@ bot: - help_prefix: "bot." - token: !REQUIRED_ENV "BOT_TOKEN" + help_prefix: "bot." + token: !ENV "BOT_TOKEN" cooldowns: # Per channel, per tag. @@ -313,3 +313,6 @@ wolfram: user_limit_day: 10 guild_limit_day: 67 key: !ENV "WOLFRAM_API_KEY" + +config: + required_keys: ['bot.token'] -- cgit v1.2.3 From 1a70f26045fb9685695790d1f331347d3922e9b7 Mon Sep 17 00:00:00 2001 From: scragly Date: Sat, 10 Nov 2018 01:26:38 +1000 Subject: Stop help output on int eval --- bot/cogs/eval.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 421fa5b53..8261b0a3b 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,8 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx): """Internal commands. Top secret!""" - await ctx.invoke(self.bot.get_command("help"), "internal") + if not ctx.invoked_subcommand: + await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin, Roles.owner) -- cgit v1.2.3 From b89bca0db42a517fe97a08a74678a2b312f29207 Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 12 Nov 2018 21:05:26 -0500 Subject: Remove hiphopify --- bot/cogs/hiphopify.py | 195 -------------------------------------------------- 1 file changed, 195 deletions(-) delete mode 100644 bot/cogs/hiphopify.py diff --git a/bot/cogs/hiphopify.py b/bot/cogs/hiphopify.py deleted file mode 100644 index 785aedca2..000000000 --- a/bot/cogs/hiphopify.py +++ /dev/null @@ -1,195 +0,0 @@ -import logging -import random - -from discord import Colour, Embed, Member -from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command - -from bot.constants import ( - Channels, Keys, - NEGATIVE_REPLIES, POSITIVE_REPLIES, - Roles, URLs -) -from bot.decorators import with_role - - -log = logging.getLogger(__name__) - - -class Hiphopify: - """ - A set of commands to moderate terrible nicknames. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} - - async def on_member_update(self, before, after): - """ - This event will trigger when someone changes their name. - At this point we will look up the user in our database and check - whether they are allowed to change their names, or if they are in - hiphop-prison. If they are not allowed, we will change it back. - :return: - """ - - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.debug( - f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in hiphop-prison..." - ) - - response = await self.bot.http_session.get( - URLs.site_hiphopify_api, - headers=self.headers, - params={"user_id": str(before.id)} - ) - - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - if after.display_name == response.get("forced_nick"): - return # Nick change was triggered by this event. Ignore. - - log.debug( - f"{after.display_name} is currently in hiphop-prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit(nick=response.get("forced_nick")) - try: - await after.send( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in hiphop-prison, you do not have permission to do so. " - "You will be allowed to change your nickname again at the following time:\n\n" - f"**{response.get('end_timestamp')}**." - ) - except Forbidden: - log.warning( - "The user tried to change their nickname while in hiphop-prison. " - "This led to the bot trying to DM the user to let them know they cannot do that, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - @command(name='hiphopify', aliases=('force_nick', 'hh')) - @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def hiphopify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): - """ - This command will force a random rapper name (like Lil' Wayne) to be the users - nickname for a specified duration. If a forced_nick is provided, it will use that instead. - - :param ctx: Discord message context - :param ta: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. - """ - - log.debug( - f"Attempting to hiphopify {member.display_name} for {duration}. " - f"forced_nick is set to {forced_nick}." - ) - - embed = Embed() - embed.colour = Colour.blurple() - - params = { - "user_id": str(member.id), - "duration": duration - } - - if forced_nick: - params["forced_nick"] = forced_nick - - response = await self.bot.http_session.post( - URLs.site_hiphopify_api, - headers=self.headers, - json=params - ) - - response = await response.json() - - if "error_message" in response: - log.warning( - "Encountered the following error when trying to hiphopify the user:\n" - f"{response.get('error_message')}" - ) - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - return await ctx.send(embed=embed) - - else: - forced_nick = response.get('forced_nick') - end_time = response.get("end_timestamp") - image_url = response.get("image_url") - - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{end_time}**.\n\n" - "If you're confused by this, please read our " - "[official nickname policy](https://pythondiscord.com/about/rules#nickname-policy)." - ) - embed.set_image(url=image_url) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log = self.bot.get_channel(Channels.modlog) - await mod_log.send( - f":middle_finger: {member.name}#{member.discriminator} (`{member.id}`) " - f"has been hiphopified by **{ctx.author.name}**. Their new nickname is `{forced_nick}`. " - f"They will not be able to change their nickname again until **{end_time}**" - ) - - # Change the nick and return the embed - log.debug("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - await ctx.send(embed=embed) - - @command(name='unhiphopify', aliases=('release_nick', 'uhh')) - @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def unhiphopify(self, ctx: Context, member: Member): - """ - This command will remove the entry from our database, allowing the user - to once again change their nickname. - - :param ctx: Discord message context - :param member: The member to unhiphopify - """ - - log.debug(f"Attempting to unhiphopify the following user: {member.display_name}") - - embed = Embed() - embed.colour = Colour.blurple() - - response = await self.bot.http_session.delete( - URLs.site_hiphopify_api, - headers=self.headers, - json={"user_id": str(member.id)} - ) - - response = await response.json() - embed.description = "User has been released from hiphop-prison." - embed.title = random.choice(POSITIVE_REPLIES) - - if "error_message" in response: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - log.warning( - f"Error encountered when trying to unhiphopify {member.display_name}:\n" - f"{response}" - ) - - log.debug(f"{member.display_name} was successfully released from hiphop-prison.") - await ctx.send(embed=embed) - - -def setup(bot): - bot.add_cog(Hiphopify(bot)) - log.info("Cog loaded: Hiphopify") -- cgit v1.2.3 From 31a1315ebca2622f35ef2ba18fb1bb990b9a6ff3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 13 Nov 2018 20:31:54 +0000 Subject: #47: Don't trigger URL filtering on a single message. --- bot/rules/links.py | 16 +++++++++++++--- config-default.yml | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/bot/rules/links.py b/bot/rules/links.py index dfeb38c61..fa4043fcb 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -20,9 +20,19 @@ async def apply( 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']: + total_links = 0 + messages_with_links = 0 + + for msg in relevant_messages: + total_matches = len(LINK_RE.findall(msg.content)) + if total_matches: + messages_with_links += 1 + total_links += total_matches + + # Only apply the filter if we found more than one message with + # links to prevent wrongfully firing the rule on users posting + # e.g. an installation log of pip packages from GitHub. + if total_links > config['max'] and messages_with_links > 1: return ( f"sent {total_links} links in {config['interval']}s", (last_message.author,), diff --git a/config-default.yml b/config-default.yml index 046c1ea56..c04571482 100644 --- a/config-default.yml +++ b/config-default.yml @@ -288,7 +288,7 @@ anti_spam: links: interval: 10 - max: 20 + max: 10 mentions: interval: 10 -- cgit v1.2.3 From 184b6d51e44915319e09b1bdf24ee26541391350 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 14 Nov 2018 10:54:15 +0000 Subject: Disabling the zalgo filter --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index c04571482..2c814b11e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -129,7 +129,7 @@ guild: filter: # What do we filter? - filter_zalgo: true + filter_zalgo: false filter_invites: true filter_domains: true watch_words: true -- cgit v1.2.3 From a961ba734bd5349109915938a57a5c34b1266301 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 14 Nov 2018 23:45:29 +0000 Subject: #75: Add a help menu for aliases. --- bot/cogs/alias.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 7b342a2d0..ea36b5ebd 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,11 +1,13 @@ +import inspect import logging -from discord import TextChannel, User +from discord import Colour, Embed, TextChannel, User from discord.ext.commands import ( - Context, clean_content, command, group + Command, Context, clean_content, command, group ) from bot.converters import TagNameConverter +from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -41,6 +43,24 @@ class Alias: await ctx.invoke(cmd, *args, **kwargs) + @command(name='aliases') + async def aliases_command(self, ctx): + """Show configured aliases on the bot.""" + + embed = Embed( + title='Configured aliases', + colour=Colour.blue() + ) + await LinePaginator.paginate( + ( + f"• `{ctx.prefix}{value.name}` " + f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" + for name, value in inspect.getmembers(self) + if isinstance(value, Command) and name.endswith('_alias') + ), + ctx, embed, empty=False, max_lines=20 + ) + @command(name="resources", aliases=("resource",), hidden=True) async def site_resources_alias(self, ctx): """ @@ -94,7 +114,7 @@ class Alias: await self.invoke(ctx, "site rules") @command(name="reload", hidden=True) - async def reload_cog_alias(self, ctx, *, cog_name: str): + async def cogs_reload_alias(self, ctx, *, cog_name: str): """ Alias for invoking cogs reload cog_name. @@ -132,7 +152,7 @@ class Alias: pass @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) - async def get_tags_command_alias( + async def tags_get_alias( self, ctx: Context, *, tag_name: TagNameConverter=None ): """ @@ -144,7 +164,7 @@ class Alias: await self.invoke(ctx, "tags get", tag_name) @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) - async def get_docs_command_alias( + async def docs_get_alias( self, ctx: Context, symbol: clean_content = None ): """ -- cgit v1.2.3 From 2b548def52b372cebc8b072dd48a51a8d3b0ec34 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 15 Nov 2018 20:44:39 +0000 Subject: Hemlock/moderation hidden param --- bot/cogs/information.py | 11 +- bot/cogs/moderation.py | 343 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 351 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index a313d2379..0f1aa75c5 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -10,6 +10,8 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) +MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator + class Information: """ @@ -22,7 +24,7 @@ class Information: self.bot = bot self.headers = {"X-API-Key": Keys.site_api} - @with_role(Roles.owner, Roles.admin, Roles.moderator) + @with_role(*MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context): """ @@ -119,12 +121,16 @@ class Information: await ctx.send(embed=embed) + @with_role(*MODERATION_ROLES) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None): + async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False): """ Returns info about a user. """ + # Validates hidden input + hidden = str(hidden) + if user is None: user = ctx.author @@ -146,6 +152,7 @@ class Information: # Infractions api_response = await self.bot.http_session.get( url=URLs.site_infractions_user.format(user_id=user.id), + params={"hidden": hidden}, headers=self.headers ) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 9165fe654..987f5fa5b 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -379,6 +379,344 @@ class Moderation(Scheduler): """) ) + # endregion + # region: Permanent shadow infractions + + @with_role(*MODERATION_ROLES) + @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note']) + async def shadow_warn(self, ctx: Context, user: User, *, reason: str = None): + """ + Create a warning infraction in the database for a user. + :param user: accepts user mention, ID, etc. + :param reason: The reason for the warning. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "warning", + "reason": reason, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + if reason is None: + result_message = f":ok_hand: warned {user.mention}." + else: + result_message = f":ok_hand: warned {user.mention} ({reason})." + + await ctx.send(result_message) + + @with_role(*MODERATION_ROLES) + @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick']) + async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): + """ + Kicks a user. + :param user: accepts user mention, ID, etc. + :param reason: The reason for the kick. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "kick", + "reason": reason, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + self.mod_log.ignore(Event.member_remove, user.id) + await user.kick(reason=reason) + + if reason is None: + result_message = f":ok_hand: kicked {user.mention}." + else: + result_message = f":ok_hand: kicked {user.mention} ({reason})." + + await ctx.send(result_message) + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.sign_out, + colour=Colour(Colours.soft_red), + title="Member shadow kicked", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + + @with_role(*MODERATION_ROLES) + @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban']) + async def shadow_ban(self, ctx: Context, user: User, *, reason: str = None): + """ + Create a permanent ban infraction in the database for a user. + :param user: Accepts user mention, ID, etc. + :param reason: The reason for the ban. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "ban", + "reason": reason, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + self.mod_log.ignore(Event.member_ban, user.id) + self.mod_log.ignore(Event.member_remove, user.id) + await ctx.guild.ban(user, reason=reason, delete_message_days=0) + + if reason is None: + result_message = f":ok_hand: permanently banned {user.mention}." + else: + result_message = f":ok_hand: permanently banned {user.mention} ({reason})." + + await ctx.send(result_message) + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_ban, + colour=Colour(Colours.soft_red), + title="Member permanently banned", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + + @with_role(*MODERATION_ROLES) + @command(name="shadow_mute", hidden=True, aliases=['shadowmute', 'smute']) + async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None): + """ + Create a permanent mute infraction in the database for a user. + :param user: Accepts user mention, ID, etc. + :param reason: The reason for the mute. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "mute", + "reason": reason, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + # add the mute role + self.mod_log.ignore(Event.member_update, user.id) + await user.add_roles(self._muted_role, reason=reason) + + if reason is None: + result_message = f":ok_hand: permanently muted {user.mention}." + else: + result_message = f":ok_hand: permanently muted {user.mention} ({reason})." + + await ctx.send(result_message) + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_mute, + colour=Colour(Colours.soft_red), + title="Member permanently muted", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + + # endregion + # region: Temporary shadow infractions + + @with_role(*MODERATION_ROLES) + @command(name="shadow_tempmute", hidden=True, aliases=["shadowtempmute, stempmute"]) + async def shadow_tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): + """ + Create a temporary mute infraction in the database for a user. + :param user: Accepts user mention, ID, etc. + :param duration: The duration for the temporary mute infraction + :param reason: The reason for the temporary mute. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "mute", + "reason": reason, + "duration": duration, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + self.mod_log.ignore(Event.member_update, user.id) + await user.add_roles(self._muted_role, reason=reason) + + infraction_object = response_object["infraction"] + infraction_expiration = infraction_object["expires_at"] + + loop = asyncio.get_event_loop() + self.schedule_expiration(loop, infraction_object) + + if reason is None: + result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." + else: + result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." + + await ctx.send(result_message) + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_mute, + colour=Colour(Colours.soft_red), + title="Member temporarily muted", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + Duration: {duration} + Expires: {infraction_expiration} + """) + ) + + @with_role(*MODERATION_ROLES) + @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"]) + async def shadow_tempban(self, ctx, user: User, duration: str, *, reason: str = None): + """ + Create a temporary ban infraction in the database for a user. + :param user: Accepts user mention, ID, etc. + :param duration: The duration for the temporary ban infraction + :param reason: The reason for the temporary ban. + """ + + try: + response = await self.bot.http_session.post( + URLs.site_infractions, + headers=self.headers, + json={ + "type": "ban", + "reason": reason, + "duration": duration, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": True + } + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + self.mod_log.ignore(Event.member_ban, user.id) + self.mod_log.ignore(Event.member_remove, user.id) + guild: Guild = ctx.guild + await guild.ban(user, reason=reason, delete_message_days=0) + + infraction_object = response_object["infraction"] + infraction_expiration = infraction_object["expires_at"] + + loop = asyncio.get_event_loop() + self.schedule_expiration(loop, infraction_object) + + if reason is None: + result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." + else: + result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." + + await ctx.send(result_message) + + # Send a log message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_ban, + colour=Colour(Colours.soft_red), + thumbnail=user.avatar_url_as(static_format="png"), + title="Member temporarily banned", + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + Duration: {duration} + Expires: {infraction_expiration} + """) + ) + # endregion # region: Remove infractions (un- commands) @@ -682,6 +1020,7 @@ class Moderation(Scheduler): URLs.site_infractions_user.format( user_id=user.id ), + params={"hidden": "True"}, headers=self.headers ) infraction_list = await response.json() @@ -707,7 +1046,7 @@ class Moderation(Scheduler): try: response = await self.bot.http_session.get( URLs.site_infractions, - params={"search": reason}, + params={"search": reason, "hidden": "True"}, headers=self.headers ) infraction_list = await response.json() @@ -803,12 +1142,14 @@ class Moderation(Scheduler): actor = guild.get_member(actor_id) active = infraction_object["active"] is True user_id = int(infraction_object["user"]["user_id"]) + hidden = infraction_object.get("hidden", False) is True lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} Status: {"__**Active**__" if active else "Inactive"} User: {self.bot.get_user(user_id)} (`{user_id}`) Type: **{infraction_object["type"]}** + Shadow: {hidden} Reason: {infraction_object["reason"] or "*None*"} Created: {infraction_object["inserted_at"]} Expires: {infraction_object["expires_at"] or "*Permanent*"} -- cgit v1.2.3 From fb9769427014f4f4c50038cb87c86228f2a58a3c Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 15 Nov 2018 20:54:03 +0000 Subject: Big Brother Watch Message Queue & Reformat --- bot/cogs/bigbrother.py | 96 +++++++++++++++++++++++++++++++++++++++++++------- bot/constants.py | 8 ++++- bot/utils/messages.py | 40 ++++++++++++++++++++- config-default.yml | 6 ++++ 4 files changed, 136 insertions(+), 14 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 3f30eb0e9..68ae4546b 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -1,16 +1,21 @@ +import asyncio import logging +import re +from collections import defaultdict, deque from typing import List, Union from discord import Color, Embed, Guild, Member, Message, TextChannel, User from discord.ext.commands import Bot, Context, group -from bot.constants import Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs +from bot.constants import BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs from bot.decorators import with_role from bot.pagination import LinePaginator - +from bot.utils import messages log = logging.getLogger(__name__) +URL_RE = re.compile(r"(https?://[^\s]+)") + class BigBrother: """User monitoring to assist with moderation.""" @@ -19,7 +24,11 @@ class BigBrother: def __init__(self, bot: Bot): self.bot = bot - self.watched_users = {} + self.watched_users = {} # { user_id: log_channel_id } + self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } + self.consuming = False + + self.bot.loop.create_task(self.get_watched_users()) def update_cache(self, api_response: List[dict]): """ @@ -43,7 +52,10 @@ class BigBrother: "but the given channel could not be found. Ignoring." ) - async def on_ready(self): + async def get_watched_users(self): + """Retrieves watched users from the API.""" + + await self.bot.wait_until_ready() async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: data = await response.json() self.update_cache(data) @@ -55,9 +67,10 @@ 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] if response.status == 204: await channel.send( - f"{Emojis.lemoneye2}:hammer: {user} got banned, so " + f"{Emojis.bb_message}:hammer: {user} got banned, so " f"`BigBrother` will no longer relay their messages to {channel}" ) @@ -65,19 +78,77 @@ class BigBrother: data = await response.json() reason = data.get('error_message', "no message provided") await channel.send( - f"{Emojis.lemoneye2}:x: {user} got banned, but trying to remove them from" + f"{Emojis.bb_message}:x: {user} got banned, but trying to remove them from" f"BigBrother's user dictionary on the API returned an error: {reason}" ) async def on_message(self, msg: Message): + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: - channel = self.watched_users[msg.author.id] - relay_content = (f"{Emojis.lemoneye2} {msg.author} sent the following " - f"in {msg.channel.mention}: {msg.clean_content}") - if msg.attachments: - relay_content += f" (with {len(msg.attachments)} attachment(s))" + if not self.consuming: + self.bot.loop.create_task(self.consume_messages()) + + log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.channel_queues[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self): + """Consumes the message queues to log watched users' messages.""" + + if not self.consuming: + self.consuming = True + log.trace("Sleeping before consuming...") + await asyncio.sleep(BigBrotherConfig.log_delay) + + log.trace("Begin consuming messages.") + channel_queues = self.channel_queues.copy() + self.channel_queues.clear() + for user_id, queues in channel_queues.items(): + for _, queue in queues.items(): + channel = self.watched_users[user_id] + + if queue: + # Send a header embed before sending all messages in the queue. + msg = queue[0] + embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + embed.set_author(name=msg.author.nick or msg.author.name, icon_url=msg.author.avatar_url) + await channel.send(embed=embed) + + while queue: + msg = queue.popleft() + log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") + await self.log_message(msg, channel) + + if self.channel_queues: + log.trace("Queue not empty; continue consumption.") + self.bot.loop.create_task(self.consume_messages()) + else: + log.trace("Done consuming messages.") + self.consuming = False + + @staticmethod + async def log_message(message: Message, destination: TextChannel): + """ + Logs a watched user's message in the given channel. + + Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview + embeds from being automatically generated. + + :param message: the message to log + :param destination: the channel in which to log the message + """ + + content = message.clean_content + if content: + # Put all non-media URLs in inline code blocks. + media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(content): + if url not in media_urls: + content = content.replace(url, f"`{url}`") + + await destination.send(content) - await channel.send(relay_content) + await messages.send_attachments(message, destination) @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -175,6 +246,7 @@ class BigBrother: if user.id in self.watched_users: del self.watched_users[user.id] + del self.channel_queues[user.id] else: log.warning(f"user {user.id} was unwatched but was not found in the cache") diff --git a/bot/constants.py b/bot/constants.py index 2a9796cc5..0e8c52c68 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -241,7 +241,7 @@ class Emojis(metaclass=YAMLGetter): green_chevron: str red_chevron: str white_chevron: str - lemoneye2: str + bb_message: str status_online: str status_offline: str @@ -446,6 +446,12 @@ class AntiSpam(metaclass=YAMLGetter): rules: Dict[str, Dict[str, int]] +class BigBrother(metaclass=YAMLGetter): + section = 'big_brother' + + log_delay: int + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False diff --git a/bot/utils/messages.py b/bot/utils/messages.py index c625beb5c..63e41983b 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,13 @@ import asyncio import contextlib +from io import BytesIO from typing import Sequence -from discord import Message +from discord import Embed, File, Message, TextChannel from discord.abc import Snowflake +from discord.errors import HTTPException + +MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes async def wait_for_deletion( @@ -70,3 +74,37 @@ async def wait_for_deletion( timeout=timeout ) await message.delete() + + +async def send_attachments(message: Message, destination: TextChannel): + """ + Re-uploads each attachment in a message to the given channel. + + Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. + If attachments are too large, they are instead grouped into a single embed which links to them. + + :param message: the message whose attachments to re-upload + :param destination: the channel in which to re-upload the attachments + """ + + large = [] + for attachment in message.attachments: + try: + # This should avoid most files that are too large, but some may get through hence the try-catch. + # Allow 512 bytes of leeway for the rest of the request. + if attachment.size <= MAX_SIZE - 512: + with BytesIO() as file: + await attachment.save(file) + await destination.send(file=File(file, filename=attachment.filename)) + else: + large.append(attachment) + except HTTPException as e: + if e.status == 413: + large.append(attachment) + else: + raise + + if large: + embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) + embed.set_footer(text="Attachments exceed upload size limit.") + await destination.send(embed=embed) diff --git a/config-default.yml b/config-default.yml index 0019d1688..1ecdfc5b9 100644 --- a/config-default.yml +++ b/config-default.yml @@ -308,11 +308,17 @@ reddit: subreddits: - 'r/Python' + wolfram: # Max requests per day. user_limit_day: 10 guild_limit_day: 67 key: !ENV "WOLFRAM_API_KEY" + +big_brother: + log_delay: 15 + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From e1f6aa718142ddc95bfa618456def7c4fd8fdd7b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Nov 2018 13:37:21 -0800 Subject: big brother: fix key error when un-watching --- bot/cogs/bigbrother.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 68ae4546b..f8bd4e0b5 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -246,7 +246,8 @@ class BigBrother: if user.id in self.watched_users: del self.watched_users[user.id] - del self.channel_queues[user.id] + if user.id in self.channel_queues: + del self.channel_queues[user.id] else: log.warning(f"user {user.id} was unwatched but was not found in the cache") -- cgit v1.2.3 From 92c8711bbba30778fe59c0b37b9f6739d50d5ae4 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 15 Nov 2018 15:37:28 -0600 Subject: Changed the message to better reflect when a !note is made Signed-off-by: Daniel Brown --- bot/cogs/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 987f5fa5b..f31e5c183 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -414,9 +414,9 @@ class Moderation(Scheduler): return if reason is None: - result_message = f":ok_hand: warned {user.mention}." + result_message = f":ok_hand: note added for {user.mention}." else: - result_message = f":ok_hand: warned {user.mention} ({reason})." + result_message = f":ok_hand: note added for {user.mention} ({reason})." await ctx.send(result_message) -- cgit v1.2.3 From 5c4fdb328f94c68ae676ca5b9ab22ee686b63258 Mon Sep 17 00:00:00 2001 From: Scragly Date: Fri, 16 Nov 2018 00:00:04 +0000 Subject: Add custom embed-based paginating help --- bot/__main__.py | 1 + bot/cogs/help.py | 714 +++++++++++++++++++++++++++++++++++++++++++++++++++++ bot/constants.py | 2 + config-default.yml | 1 + 4 files changed, 718 insertions(+) create mode 100644 bot/cogs/help.py diff --git a/bot/__main__.py b/bot/__main__.py index ab66492bb..5e6c7f603 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -49,6 +49,7 @@ bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") +bot.load_extension("bot.cogs.help") # Only load this in production if not DEBUG_MODE: diff --git a/bot/cogs/help.py b/bot/cogs/help.py new file mode 100644 index 000000000..f229fa00b --- /dev/null +++ b/bot/cogs/help.py @@ -0,0 +1,714 @@ +import asyncio +import inspect +import itertools +from collections import namedtuple +from contextlib import suppress + +from discord import Colour, Embed, HTTPException +from discord.ext import commands +from fuzzywuzzy import fuzz, process + +from bot import constants +from bot.pagination import ( + DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, + LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, +) + +REACTIONS = { + FIRST_EMOJI: 'first', + LEFT_EMOJI: 'back', + RIGHT_EMOJI: 'next', + LAST_EMOJI: 'end', + DELETE_EMOJI: 'stop' +} + +Cog = namedtuple('Cog', ['name', 'description', 'commands']) + + +class HelpQueryNotFound(ValueError): + """ + Raised when a HelpSession Query doesn't match a command or cog. + + Contains the custom attribute of ``possible_matches``. + + Attributes + ---------- + possible_matches: dict + Any commands that were close to matching the Query. + The possible matched command names are the keys. + The likeness match scores are the values. + """ + + def __init__(self, arg, possible_matches=None): + super().__init__(arg) + self.possible_matches = possible_matches + + +class HelpSession: + """ + An interactive session for bot and command help output. + + Attributes + ---------- + title: str + The title of the help message. + query: Union[:class:`discord.ext.commands.Bot`, + :class:`discord.ext.commands.Command] + description: str + The description of the query. + pages: list[str] + A list of the help content split into manageable pages. + message: :class:`discord.Message` + The message object that's showing the help contents. + destination: :class:`discord.abc.Messageable` + Where the help message is to be sent to. + """ + + def __init__(self, ctx, *command, cleanup=False, only_can_run=True, show_hidden=False, max_lines=15): + """ + Creates an instance of the HelpSession class. + + Parameters + ---------- + ctx: :class:`discord.Context` + The context of the invoked help command. + *command: str + A variable argument of the command being queried. + cleanup: Optional[bool] + Set to ``True`` to have the message deleted on timeout. + If ``False``, it will clear all reactions on timeout. + Defaults to ``False``. + only_can_run: Optional[bool] + Set to ``True`` to hide commands the user can't run. + Defaults to ``False``. + show_hidden: Optional[bool] + Set to ``True`` to include hidden commands. + Defaults to ``False``. + max_lines: Optional[int] + Sets the max number of lines the paginator will add to a + single page. + Defaults to 20. + """ + + self._ctx = ctx + self._bot = ctx.bot + self.title = "Command Help" + + # set the query details for the session + if command: + query_str = ' '.join(command) + self.query = self._get_query(query_str) + self.description = self.query.description or self.query.help + else: + self.query = ctx.bot + self.description = self.query.description + self.author = ctx.author + self.destination = ctx.author if ctx.bot.pm_help else ctx.channel + + # set the config for the session + self._cleanup = cleanup + self._only_can_run = only_can_run + self._show_hidden = show_hidden + self._max_lines = max_lines + + # init session states + self._pages = None + self._current_page = 0 + self.message = None + self._timeout_task = None + self.reset_timeout() + + def _get_query(self, query): + """ + Attempts to match the provided query with a valid command or cog. + + Parameters + ---------- + query: str + The joined string representing the session query. + + Returns + ------- + Union[:class:`discord.ext.commands.Command`, :class:`Cog`] + """ + + command = self._bot.get_command(query) + if command: + return command + + cog = self._bot.cogs.get(query) + if cog: + return Cog( + name=cog.__class__.__name__, + description=inspect.getdoc(cog), + commands=[c for c in self._bot.commands if c.instance is cog] + ) + + self._handle_not_found(query) + + def _handle_not_found(self, query): + """ + Handles when a query does not match a valid command or cog. + + Will pass on possible close matches along with the + ``HelpQueryNotFound`` exception. + + Parameters + ---------- + query: str + The full query that was requested. + + Raises + ------ + HelpQueryNotFound + """ + + # combine command and cog names + choices = list(self._bot.all_commands) + list(self._bot.cogs) + + result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) + + raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) + + async def timeout(self, seconds=30): + """ + Waits for a set number of seconds, then stops the help session. + + Parameters + ---------- + seconds: int + Number of seconds to wait. + """ + + await asyncio.sleep(seconds) + await self.stop() + + def reset_timeout(self): + """ + Cancels the original timeout task and sets it again from the start. + """ + + # cancel original if it exists + if self._timeout_task: + if not self._timeout_task.cancelled(): + self._timeout_task.cancel() + + # recreate the timeout task + self._timeout_task = self._bot.loop.create_task(self.timeout()) + + async def on_reaction_add(self, reaction, user): + """ + Event handler for when reactions are added on the help message. + + Parameters + ---------- + reaction: :class:`discord.Reaction` + The reaction that was added. + user: :class:`discord.User` + The user who added the reaction. + """ + + # ensure it was the relevant session message + if reaction.message.id != self.message.id: + return + + # ensure it was the session author who reacted + if user.id != self.author.id: + return + + emoji = str(reaction.emoji) + + # check if valid action + if emoji not in REACTIONS: + return + + self.reset_timeout() + + # Run relevant action method + action = getattr(self, f'do_{REACTIONS[emoji]}', None) + if action: + await action() + + # remove the added reaction to prep for re-use + with suppress(HTTPException): + await self.message.remove_reaction(reaction, user) + + async def on_message_delete(self, message): + """ + Closes the help session when the help message is deleted. + + Parameters + ---------- + message: :class:`discord.Message` + The message that was deleted. + """ + + if message.id == self.message.id: + await self.stop() + + async def prepare(self): + """ + Sets up the help session pages, events, message and reactions. + """ + + # create paginated content + await self.build_pages() + + # setup listeners + self._bot.add_listener(self.on_reaction_add) + self._bot.add_listener(self.on_message_delete) + + # Send the help message + await self.update_page() + self.add_reactions() + + def add_reactions(self): + """ + Adds the relevant reactions to the help message based on if + pagination is required. + """ + + # if paginating + if len(self._pages) > 1: + for reaction in REACTIONS: + self._bot.loop.create_task(self.message.add_reaction(reaction)) + + # if single-page + else: + self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) + + def _category_key(self, cmd): + """ + Returns a cog name of a given command. Used as a key for + ``sorted`` and ``groupby``. + + A zero width space is used as a prefix for results with no cogs + to force them last in ordering. + + Parameters + ---------- + cmd: :class:`discord.ext.commands.Command` + The command object being checked. + + Returns + ------- + str + """ + + cog = cmd.cog_name + return f'**{cog}**' if cog else f'**\u200bNo Category:**' + + def _get_command_params(self, cmd): + """ + Returns the command usage signature. + + This is a custom implementation of ``command.signature`` in + order to format the command signature without aliases. + + Parameters + ---------- + cmd: :class:`discord.ext.commands.Command` + The command object to get the parameters of. + + Returns + ------- + str + """ + + results = [] + for name, param in cmd.clean_params.items(): + + # if argument has a default value + if param.default is not param.empty: + + if isinstance(param.default, str): + show_default = param.default + else: + show_default = param.default is not None + + # if default is not an empty string or None + if show_default: + results.append(f'[{name}={param.default}]') + else: + results.append(f'[{name}]') + + # if variable length argument + elif param.kind == param.VAR_POSITIONAL: + results.append(f'[{name}...]') + + # if required + else: + results.append(f'<{name}>') + + return f"{cmd.name} {' '.join(results)}" + + async def build_pages(self): + """ + Builds the list of content pages to be paginated through in the + help message. + + Returns + ------- + list[str] + """ + + # Use LinePaginator to restrict embed line height + paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) + + # show signature if query is a command + if isinstance(self.query, commands.Command): + signature = self._get_command_params(self.query) + paginator.add_line(f'**```{signature}```**') + + if not await self.query.can_run(self._ctx): + paginator.add_line('***You cannot run this command.***\n') + + # show name if query is a cog + if isinstance(self.query, Cog): + paginator.add_line(f'**{self.query.name}**') + + if self.description: + paginator.add_line(f'*{self.description}*') + + # list all children commands of the queried object + if isinstance(self.query, (commands.GroupMixin, Cog)): + + # remove hidden commands if session is not wanting hiddens + if not self._show_hidden: + filtered = [c for c in self.query.commands if not c.hidden] + else: + filtered = self.query.commands + + # if after filter there are no commands, finish up + if not filtered: + self._pages = paginator.pages + return + + # set category to Commands if cog + if isinstance(self.query, Cog): + grouped = (('**Commands:**', self.query.commands),) + + # set category to Subcommands if command + elif isinstance(self.query, commands.Command): + grouped = (('**Subcommands:**', self.query.commands),) + + # otherwise sort and organise all commands into categories + else: + cat_sort = sorted(filtered, key=self._category_key) + grouped = itertools.groupby(cat_sort, key=self._category_key) + + # process each category + for category, cmds in grouped: + cmds = sorted(cmds, key=lambda c: c.name) + + # if there are no commands, skip category + if len(cmds) == 0: + continue + + cat_cmds = [] + + # format details for each child command + for command in cmds: + + # skip if hidden and hide if session is set to + if command.hidden and not self._show_hidden: + continue + + # see if the user can run the command + strikeout = '' + can_run = await command.can_run(self._ctx) + if not can_run: + # skip if we don't show commands they can't run + if self._only_can_run: + continue + strikeout = '~~' + + signature = self._get_command_params(command) + prefix = constants.Bot.help_prefix + info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" + + # handle if the command has no docstring + if command.short_doc: + cat_cmds.append(f'{info}\n*{command.short_doc}*') + else: + cat_cmds.append(f'{info}\n*No details provided.*') + + # state var for if the category should be added next + print_cat = 1 + new_page = True + + for details in cat_cmds: + + # keep details together, paginating early if it won't fit + lines_adding = len(details.split('\n')) + print_cat + if paginator._linecount + lines_adding > self._max_lines: + paginator._linecount = 0 + new_page = True + paginator.close_page() + + # new page so print category title again + print_cat = 1 + + if print_cat: + if new_page: + paginator.add_line('') + paginator.add_line(category) + print_cat = 0 + + paginator.add_line(details) + + # save organised pages to session + self._pages = paginator.pages + + def embed_page(self, page_number=0): + """ + Returns an Embed with the requested page formatted within. + + Parameters + ---------- + page_number: int + The page to be retrieved. Zero indexed. + + Returns + ------- + :class:`discord.Embed` + """ + + embed = Embed() + + # if command or cog, add query to title for pages other than first + if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: + title = f'Command Help | "{self.query.name}"' + else: + title = self.title + + embed.set_author(name=title, icon_url=constants.Icons.questionmark) + embed.description = self._pages[page_number] + + # add page counter to footer if paginating + page_count = len(self._pages) + if page_count > 1: + embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') + + return embed + + async def update_page(self, page_number=0): + """ + Sends the intial message, or changes the existing one to the + given page number. + + Parameters + ---------- + page_number: int + The page number to show in the help message. + """ + + self._current_page = page_number + embed_page = self.embed_page(page_number) + + if not self.message: + self.message = await self.destination.send(embed=embed_page) + else: + await self.message.edit(embed=embed_page) + + @classmethod + async def start(cls, ctx, *command, **options): + """ + Create and begin a help session based on the given command + context. + + Parameters + ---------- + ctx: :class:`discord.ext.commands.Context` + The context of the invoked help command. + *command: str + A variable argument of the command being queried. + cleanup: Optional[bool] + Set to ``True`` to have the message deleted on session end. + Defaults to ``False``. + only_can_run: Optional[bool] + Set to ``True`` to hide commands the user can't run. + Defaults to ``False``. + show_hidden: Optional[bool] + Set to ``True`` to include hidden commands. + Defaults to ``False``. + max_lines: Optional[int] + Sets the max number of lines the paginator will add to a + single page. + Defaults to 20. + + Returns + ------- + :class:`HelpSession` + """ + + session = cls(ctx, *command, **options) + await session.prepare() + + return session + + async def stop(self): + """ + Stops the help session, removes event listeners and attempts to + delete the help message. + """ + + self._bot.remove_listener(self.on_reaction_add) + self._bot.remove_listener(self.on_message_delete) + + # ignore if permission issue, or the message doesn't exist + with suppress(HTTPException, AttributeError): + if self._cleanup: + await self.message.delete() + else: + await self.message.clear_reactions() + + @property + def is_first_page(self): + """ + A bool reflecting if session is currently showing the first page. + + Returns + ------- + bool + """ + + return self._current_page == 0 + + @property + def is_last_page(self): + """ + A bool reflecting if the session is currently showing the last page. + + Returns + ------- + bool + """ + + return self._current_page == (len(self._pages)-1) + + async def do_first(self): + """ + Event that is called when the user requests the first page. + """ + + if not self.is_first_page: + await self.update_page(0) + + async def do_back(self): + """ + Event that is called when the user requests the previous page. + """ + + if not self.is_first_page: + await self.update_page(self._current_page-1) + + async def do_next(self): + """ + Event that is called when the user requests the next page. + """ + + if not self.is_last_page: + await self.update_page(self._current_page+1) + + async def do_end(self): + """ + Event that is called when the user requests the last page. + """ + + if not self.is_last_page: + await self.update_page(len(self._pages)-1) + + async def do_stop(self): + """ + Event that is called when the user requests to stop the help session. + """ + + await self.message.delete() + + +class Help: + """ + Custom Embed Pagination Help feature + """ + @commands.command('help') + async def new_help(self, ctx, *commands): + """ + Shows Command Help. + """ + + try: + await HelpSession.start(ctx, *commands) + except HelpQueryNotFound as error: + embed = Embed() + embed.colour = Colour.red() + embed.title = str(error) + + if error.possible_matches: + matches = '\n'.join(error.possible_matches.keys()) + embed.description = f'**Did you mean:**\n`{matches}`' + + await ctx.send(embed=embed) + + +def unload(bot): + """ + Reinstates the original help command. + + This is run if the cog raises an exception on load, or if the + extension is unloaded. + + Parameters + ---------- + bot: :class:`discord.ext.commands.Bot` + The discord bot client. + """ + + bot.remove_command('help') + bot.add_command(bot._old_help) + + +def setup(bot): + """ + The setup for the help extension. + + This is called automatically on `bot.load_extension` being run. + + Stores the original help command instance on the ``bot._old_help`` + attribute for later reinstatement, before removing it from the + command registry so the new help command can be loaded successfully. + + If an exception is raised during the loading of the cog, ``unload`` + will be called in order to reinstate the original help command. + + Parameters + ---------- + bot: `discord.ext.commands.Bot` + The discord bot client. + """ + + bot._old_help = bot.get_command('help') + bot.remove_command('help') + + try: + bot.add_cog(Help()) + except Exception: + unload(bot) + raise + + +def teardown(bot): + """ + The teardown for the help extension. + + This is called automatically on `bot.unload_extension` being run. + + Calls ``unload`` in order to reinstate the original help command. + + Parameters + ---------- + bot: `discord.ext.commands.Bot` + The discord bot client. + """ + + unload(bot) diff --git a/bot/constants.py b/bot/constants.py index 0e8c52c68..e2303efb2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -297,6 +297,8 @@ class Icons(metaclass=YAMLGetter): remind_green: str remind_red: str + questionmark: str + class CleanMessages(metaclass=YAMLGetter): section = "bot" diff --git a/config-default.yml b/config-default.yml index 1ecdfc5b9..4fb8884e8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -77,6 +77,7 @@ style: remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" + questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" guild: id: 267624335836053506 -- cgit v1.2.3 From 23653c207445df566b8f400a9b283b662eb8844f Mon Sep 17 00:00:00 2001 From: scragly Date: Fri, 16 Nov 2018 15:06:24 +1000 Subject: Fix bot:prefix config and usage --- bot/__main__.py | 2 +- bot/cogs/help.py | 2 +- bot/constants.py | 2 +- config-default.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 5e6c7f603..3c40a3243 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -12,7 +12,7 @@ from bot.utils.service_discovery import wait_for_rmq log = logging.getLogger(__name__) bot = Bot( - command_prefix=when_mentioned_or("!"), + command_prefix=when_mentioned_or(BotConfig.prefix), activity=Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000 diff --git a/bot/cogs/help.py b/bot/cogs/help.py index f229fa00b..144286c56 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -424,7 +424,7 @@ class HelpSession: strikeout = '~~' signature = self._get_command_params(command) - prefix = constants.Bot.help_prefix + prefix = constants.Bot.prefix info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" # handle if the command has no docstring diff --git a/bot/constants.py b/bot/constants.py index e2303efb2..266bd5eee 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -191,7 +191,7 @@ class YAMLGetter(type): class Bot(metaclass=YAMLGetter): section = "bot" - help_prefix: str + prefix: str token: str diff --git a/config-default.yml b/config-default.yml index 4fb8884e8..3598caa45 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,5 +1,5 @@ bot: - help_prefix: "bot." + prefix: "!" token: !ENV "BOT_TOKEN" cooldowns: -- cgit v1.2.3 From 5aac5642a93b9aafda43ab74ca2515be4caaffdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Nov 2018 22:22:15 -0800 Subject: eval: fix bot typing while waiting for delete reaction for eval results --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 184a7545d..1b51da843 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -184,7 +184,7 @@ class Snekbox: msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" response = await ctx.send(msg) - await wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)) else: await ctx.send( -- cgit v1.2.3 From cbdd55daaab88aafc3e3d9ef14ea056927562693 Mon Sep 17 00:00:00 2001 From: scragly Date: Fri, 16 Nov 2018 18:27:22 +1000 Subject: Add non-cached user fallback --- bot/cogs/moderation.py | 52 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index f31e5c183..2ab59f12d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,10 +1,13 @@ import asyncio import logging import textwrap +import typing from aiohttp import ClientError from discord import Colour, Embed, Guild, Member, Object, User -from discord.ext.commands import Bot, Context, command, group +from discord.ext.commands import ( + BadArgument, BadUnionArgument, Bot, Context, command, group +) from bot import constants from bot.cogs.modlog import ModLog @@ -20,6 +23,17 @@ log = logging.getLogger(__name__) MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator +def proxy_user(user_id: str) -> Object: + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + user = Object(user_id) + user.mention = user.id + user.avatar_url_as = lambda static_format: None + return user + + class Moderation(Scheduler): """ Rowboat replacement moderation tools. @@ -52,7 +66,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="warn") - async def warn(self, ctx: Context, user: User, *, reason: str = None): + async def warn(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): """ Create a warning infraction in the database for a user. :param user: accepts user mention, ID, etc. @@ -89,7 +103,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="kick") - async def kick(self, ctx, user: Member, *, reason: str = None): + async def kick(self, ctx: Context, user: Member, *, reason: str = None): """ Kicks a user. :param user: accepts user mention, ID, etc. @@ -142,7 +156,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="ban") - async def ban(self, ctx: Context, user: User, *, reason: str = None): + async def ban(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -316,7 +330,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="tempban") - async def tempban(self, ctx, user: User, duration: str, *, reason: str = None): + async def tempban(self, ctx: Context, user: typing.Union[User, proxy_user], duration: str, *, reason: str = None): """ Create a temporary ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -384,7 +398,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note']) - async def shadow_warn(self, ctx: Context, user: User, *, reason: str = None): + async def shadow_warn(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): """ Create a warning infraction in the database for a user. :param user: accepts user mention, ID, etc. @@ -476,7 +490,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: User, *, reason: str = None): + async def shadow_ban(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -653,7 +667,9 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"]) - async def shadow_tempban(self, ctx, user: User, duration: str, *, reason: str = None): + async def shadow_tempban( + self, ctx: Context, user: typing.Union[User, proxy_user], duration: str, *, reason: str = None + ): """ Create a temporary ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -722,7 +738,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="unmute") - async def unmute(self, ctx, user: Member): + async def unmute(self, ctx: Context, user: Member): """ Deactivates the active mute infraction for a user. :param user: Accepts user mention, ID, etc. @@ -773,7 +789,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="unban") - async def unban(self, ctx, user: User): + async def unban(self, ctx: Context, user: typing.Union[User, proxy_user]): """ Deactivates the active ban infraction for a user. :param user: Accepts user mention, ID, etc. @@ -841,7 +857,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="duration") - async def edit_duration(self, ctx, infraction_id: str, duration: str): + async def edit_duration(self, ctx: Context, infraction_id: str, duration: str): """ Sets the duration of the given infraction, relative to the time of updating. :param infraction_id: the id (UUID) of the infraction @@ -924,7 +940,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx, infraction_id: str, *, reason: str): + async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str): """ Sets the reason of the given infraction. :param infraction_id: the id (UUID) of the infraction @@ -1010,7 +1026,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx, user: User): + async def search_user(self, ctx: Context, user: typing.Union[User, proxy_user]): """ Search for infractions by member. """ @@ -1038,7 +1054,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx, reason: str): + async def search_reason(self, ctx: Context, reason: str): """ Search for infractions by their reason. Use Re2 for matching. """ @@ -1111,6 +1127,7 @@ class Moderation(Scheduler): un-schedule an expiration task. :param infraction_object: the infraction in question """ + guild: Guild = self.bot.get_guild(constants.Guild.id) user_id = int(infraction_object["user"]["user_id"]) infraction_type = infraction_object["type"] @@ -1124,7 +1141,7 @@ class Moderation(Scheduler): else: log.warning(f"Failed to un-mute user: {user_id} (not found)") elif infraction_type == "ban": - user: User = self.bot.get_user(user_id) + user: Object = Object(user_id) await guild.unban(user) await self.bot.http_session.patch( @@ -1162,6 +1179,11 @@ class Moderation(Scheduler): # endregion + async def __error(self, ctx, error): + if isinstance(error, BadUnionArgument): + if User in error.converters: + await ctx.send(str(error.errors[0])) + def setup(bot): bot.add_cog(Moderation(bot)) -- cgit v1.2.3 From 3583cfd5b0b7ae3af8674339f84f807879af2b6f Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 10:43:35 +0000 Subject: Initial Azure Pipelines test --- azure-pipelines.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..a8dcd67e0 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,40 @@ +# https://aka.ms/yaml + +pool: + vmImage: 'Ubuntu 16.04' + +variables: + ENV LIBRARY_PATH: /lib:/usr/lib + ENV PIPENV_HIDE_EMOJIS: 1 + ENV PIPENV_IGNORE_VIRTUALENVS: 1 + ENV PIPENV_NOSPIN: 1 + ENV PIPENV_VENV_IN_PROJECT: 1 + +jobs: +- job: test + displayName: 'Lint and test' + + pool: + vmImage: 'Ubuntu 16.04' + + variables: + PIPENV_CACHE_DIR: ".cache/pipenv" + PIP_CACHE_DIR: ".cache/pip" + + steps: + - script: apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev + displayName: 'Install base dependencies' + + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.7.x' + addToPath: true + + - script: pip install pipenv + displayName: 'Install pipenv' + + - script: pipenv install --dev --deploy --system + displayName: 'Install project using pipenv' + + - script: python -m flake8 + displayName: 'Run linter' -- cgit v1.2.3 From 16a6fa5375b63e8c4ab79b03ca45397d5f7f9a27 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 10:45:19 +0000 Subject: Azure: 'jobs' is not allowed. 'pool' is already defined and is mutually exclusive. --- azure-pipelines.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a8dcd67e0..ee540b70d 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,8 +1,5 @@ # https://aka.ms/yaml -pool: - vmImage: 'Ubuntu 16.04' - variables: ENV LIBRARY_PATH: /lib:/usr/lib ENV PIPENV_HIDE_EMOJIS: 1 -- cgit v1.2.3 From f58511206fac167e98ce0d6f114916c1ab6f0bc5 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 10:47:21 +0000 Subject: Azure: Should use sudo; we don't run as root --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ee540b70d..2b8c9b9c9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -19,7 +19,7 @@ jobs: PIP_CACHE_DIR: ".cache/pip" steps: - - script: apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev + - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev displayName: 'Install base dependencies' - task: UsePythonVersion@0 @@ -27,7 +27,7 @@ jobs: versionSpec: '3.7.x' addToPath: true - - script: pip install pipenv + - script: sudo pip install pipenv displayName: 'Install pipenv' - script: pipenv install --dev --deploy --system -- cgit v1.2.3 From a27dca2d9be052e5fdf0156f9a0e2b8a6fe23c19 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 11:28:54 +0000 Subject: Azure: Build and push container --- azure-pipelines.yml | 21 +++++++++++++++++++++ scripts/deploy-azure.sh | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 scripts/deploy-azure.sh diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2b8c9b9c9..3f063ea41 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -23,6 +23,7 @@ jobs: displayName: 'Install base dependencies' - task: UsePythonVersion@0 + displayName: 'Set Python version' inputs: versionSpec: '3.7.x' addToPath: true @@ -35,3 +36,23 @@ jobs: - script: python -m flake8 displayName: 'Run linter' + +- job: build + displayName: 'Build containers' + + trigger: + branches: + include: + - master + + steps: + - task: Docker@1 + displayName: 'Login: Docker Hub' + + inputs: + containerregistrytype: 'Container Registry' + dockerRegistryEndpoint: 'Docker Hub' + command: 'login' + + - bash: sh scripts/deploy-azure.sh + displayName: 'Build and deploy containers' diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh new file mode 100644 index 000000000..f18407c8b --- /dev/null +++ b/scripts/deploy-azure.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) + +if [ $changed_lines != '0' ]; then + echo "base.Dockerfile was changed" + + echo "Building bot base" + docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . + + echo "Pushing image to Docker Hub" + docker push pythondiscord/bot-base:latest +else + echo "base.Dockerfile was not changed, not building" +fi + +echo "Building image" +docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . + +echo "Pushing image" +docker push pythondiscord/bot:latest + +#echo "Deploying container" +#curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK -- cgit v1.2.3 From 3ece07a0f25ee4f11d2422a0ab8241212026e778 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 11:36:02 +0000 Subject: Azure: Move branch trigger to Docker build script --- azure-pipelines.yml | 5 ----- scripts/deploy-azure.sh | 37 +++++++++++++++++++++---------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3f063ea41..22f214226 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -40,11 +40,6 @@ jobs: - job: build displayName: 'Build containers' - trigger: - branches: - include: - - master - steps: - task: Docker@1 displayName: 'Login: Docker Hub' diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index f18407c8b..4df5cb0fc 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,24 +1,29 @@ #!/bin/bash -changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) +# Build and deploy on master branch +if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]]; then + changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) -if [ $changed_lines != '0' ]; then - echo "base.Dockerfile was changed" + if [ $changed_lines != '0' ]; then + echo "base.Dockerfile was changed" - echo "Building bot base" - docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . + echo "Building bot base" + docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . - echo "Pushing image to Docker Hub" - docker push pythondiscord/bot-base:latest -else - echo "base.Dockerfile was not changed, not building" -fi + echo "Pushing image to Docker Hub" + docker push pythondiscord/bot-base:latest + else + echo "base.Dockerfile was not changed, not building" + fi -echo "Building image" -docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . + echo "Building image" + docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . -echo "Pushing image" -docker push pythondiscord/bot:latest + echo "Pushing image" + docker push pythondiscord/bot:latest -#echo "Deploying container" -#curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK + # echo "Deploying container" + # curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK +else + echo "Skipping deploy" +fi \ No newline at end of file -- cgit v1.2.3 From 1c082d81292fe845031b69d2670efb119a1b8256 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 11:40:15 +0000 Subject: Azure: Remove space from Docker service name --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 22f214226..21e2455b3 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,7 +46,7 @@ jobs: inputs: containerregistrytype: 'Container Registry' - dockerRegistryEndpoint: 'Docker Hub' + dockerRegistryEndpoint: 'DockerHub' command: 'login' - bash: sh scripts/deploy-azure.sh -- cgit v1.2.3 From 4c68fc8761573fb91fa9afcf28a4979160878800 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 11:51:53 +0000 Subject: Azure: Build job should depend on test --- azure-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 21e2455b3..bb8d7999e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -39,6 +39,7 @@ jobs: - job: build displayName: 'Build containers' + dependsOn: 'test' steps: - task: Docker@1 -- cgit v1.2.3 From ed14faee08f82b6569517fae6e33c43abda47eed Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 11:56:56 +0000 Subject: Azure: Attempt to fix differing build script syntax --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 4df5cb0fc..378cceaf6 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build and deploy on master branch -if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]]; then +if [ $BUILD_SOURCEBRANCHNAME == 'master' ]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From 50ca98c9e854e2841ca18d18b8b41c47db117687 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 12:01:34 +0000 Subject: Azure: I hate bash. --- azure-pipelines.yml | 2 +- scripts/deploy-azure.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index bb8d7999e..bce6dbce4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -50,5 +50,5 @@ jobs: dockerRegistryEndpoint: 'DockerHub' command: 'login' - - bash: sh scripts/deploy-azure.sh + - bash: bash scripts/deploy-azure.sh displayName: 'Build and deploy containers' diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 378cceaf6..4df5cb0fc 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build and deploy on master branch -if [ $BUILD_SOURCEBRANCHNAME == 'master' ]; then +if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From 433871f52e5af50234b65ba35d198f907ef5565f Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 12:28:59 +0000 Subject: Azure: Finish deployment; skip if PR GitLab: Remove deployment step --- .gitlab-ci.yml | 10 ---------- azure-pipelines.yml | 4 ++-- scripts/deploy-azure.sh | 10 ++++++---- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f7aee8165..44126ded9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,13 +26,3 @@ test: - python -m flake8 - ls /root/.cache/ -build: - tags: - - docker - - services: - - docker:dind - - stage: build - script: - - sh scripts/deploy.sh diff --git a/azure-pipelines.yml b/azure-pipelines.yml index bce6dbce4..534742f4f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ variables: jobs: - job: test - displayName: 'Lint and test' + displayName: 'Lint & Test' pool: vmImage: 'Ubuntu 16.04' @@ -38,7 +38,7 @@ jobs: displayName: 'Run linter' - job: build - displayName: 'Build containers' + displayName: 'Build Containers' dependsOn: 'test' steps: diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 4df5cb0fc..659a297bf 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,7 +1,9 @@ #!/bin/bash -# Build and deploy on master branch -if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]]; then +if [[ ]] + +# Build and deploy on master branch, only if not a pull request +if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]] && [[ -z "${SYSTEM_PULLREQUEST_PULLREQUESTID}" ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then @@ -22,8 +24,8 @@ if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]]; then echo "Pushing image" docker push pythondiscord/bot:latest - # echo "Deploying container" - # curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK + echo "Deploying container" + curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK else echo "Skipping deploy" fi \ No newline at end of file -- cgit v1.2.3 From 138662eb447282722fee307e8d923039d1039124 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 12:33:13 +0000 Subject: Azure: Did I ever say I hate bash? --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 659a297bf..fe1aeb245 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -3,7 +3,7 @@ if [[ ]] # Build and deploy on master branch, only if not a pull request -if [[ $BUILD_SOURCEBRANCHNAME == 'master' ]] && [[ -z "${SYSTEM_PULLREQUEST_PULLREQUESTID}" ]]; then +if [[ $BUILD_SOURCEBRANCHNAME == 'master' && -z "${SYSTEM_PULLREQUEST_PULLREQUESTID}" ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From 0444287263ef64f383c29f9b8407b075a889cd59 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 12:39:44 +0000 Subject: Azure: Why does Bash have to be so awful? --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index fe1aeb245..81037c217 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -3,7 +3,7 @@ if [[ ]] # Build and deploy on master branch, only if not a pull request -if [[ $BUILD_SOURCEBRANCHNAME == 'master' && -z "${SYSTEM_PULLREQUEST_PULLREQUESTID}" ]]; then +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z "${SYSTEM_PULLREQUEST_PULLREQUESTID}") ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From db7e0a596e41e854b127db15bb141a254ce7314b Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:03:01 +0000 Subject: Azure: Maybe bash requires single quotes? I hate bash. --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 81037c217..157d77e7f 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -3,7 +3,7 @@ if [[ ]] # Build and deploy on master branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z "${SYSTEM_PULLREQUEST_PULLREQUESTID}") ]]; then +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z '$SYSTEM_PULLREQUEST_PULLREQUESTID') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From 550e491517f6d6bf8757cfb5a381537b4e391dbd Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:13:57 +0000 Subject: Azure: Let's bash Bash --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 157d77e7f..187aeda00 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -3,7 +3,7 @@ if [[ ]] # Build and deploy on master branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z '$SYSTEM_PULLREQUEST_PULLREQUESTID') ]]; then +if [ [$BUILD_SOURCEBRANCHNAME == 'master'] && [-z '$SYSTEM_PULLREQUEST_PULLREQUESTID'] ]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From a8a76263de738726eced0cee2e43ef212a5f5ba7 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:20:45 +0000 Subject: Azure: Where'd this extra line come from? --- scripts/deploy-azure.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 187aeda00..9f64b52a2 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,9 +1,7 @@ #!/bin/bash -if [[ ]] - # Build and deploy on master branch, only if not a pull request -if [ [$BUILD_SOURCEBRANCHNAME == 'master'] && [-z '$SYSTEM_PULLREQUEST_PULLREQUESTID'] ]; then +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z '$SYSTEM_PULLREQUEST_PULLREQUESTID') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From c7be43a3f4ec885d7712ae62d8a96cdde9148d15 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:24:47 +0000 Subject: Azure: Unfortunately I have to check this manually --- scripts/deploy-azure.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 9f64b52a2..f700a4847 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,5 +1,7 @@ #!/bin/bash +echo "PR ID: $SYSTEM_PULLREQUEST_PULLREQUESTID" + # Build and deploy on master branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z '$SYSTEM_PULLREQUEST_PULLREQUESTID') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) -- cgit v1.2.3 From 42e0d51b5c3b87a088d27fb046005e5d8b0c9cf1 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:31:16 +0000 Subject: Azure: Hopefully that'll do it --- scripts/deploy-azure.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index f700a4847..23573df82 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,9 +1,9 @@ #!/bin/bash -echo "PR ID: $SYSTEM_PULLREQUEST_PULLREQUESTID" +export SYSTEM_PULLREQUEST_PULLREQUESTID = $SYSTEM_PULLREQUEST_PULLREQUESTID | xargs # Build and deploy on master branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && (-z '$SYSTEM_PULLREQUEST_PULLREQUESTID') ]]; then +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) if [ $changed_lines != '0' ]; then -- cgit v1.2.3 From 463dee7f84c9bc32dc6d07d177a1d42a200b7695 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:36:06 +0000 Subject: Azure: I'm rewriting this in Python someday --- scripts/deploy-azure.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 23573df82..5f7666bb0 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,6 +1,6 @@ #!/bin/bash -export SYSTEM_PULLREQUEST_PULLREQUESTID = $SYSTEM_PULLREQUEST_PULLREQUESTID | xargs +export SYSTEM_PULLREQUEST_PULLREQUESTID="$(echo -e '${SYSTEM_PULLREQUEST_PULLREQUESTID}' | xargs)" # Build and deploy on master branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then -- cgit v1.2.3 From c8641271431c724acab3fbaf7f424718becefed8 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:40:29 +0000 Subject: Azure: It's amusing that bash is the problem today --- scripts/deploy-azure.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 5f7666bb0..84509f461 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,7 +1,5 @@ #!/bin/bash -export SYSTEM_PULLREQUEST_PULLREQUESTID="$(echo -e '${SYSTEM_PULLREQUEST_PULLREQUESTID}' | xargs)" - # Build and deploy on master branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) -- cgit v1.2.3 From b04abeb09eb287db541e6ef696f81819832428ac Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:46:50 +0000 Subject: Azure: Switch to shell script task instead of calling bash ourselves --- azure-pipelines.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 534742f4f..b9fe8beb0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -50,5 +50,8 @@ jobs: dockerRegistryEndpoint: 'DockerHub' command: 'login' - - bash: bash scripts/deploy-azure.sh + - task: ShellScript@2 displayName: 'Build and deploy containers' + + inputs: + scriptPath: scripts/deploy-azure.sh -- cgit v1.2.3 From 343c1fbdbea110d9b4aca00c4182accb977d2202 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 13:56:29 +0000 Subject: Azure: OK, turns out secret variables must be task args and otherwise aren't available to the running script --- azure-pipelines.yml | 1 + scripts/deploy-azure.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b9fe8beb0..fe7486a5c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -55,3 +55,4 @@ jobs: inputs: scriptPath: scripts/deploy-azure.sh + args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)' diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 84509f461..8cb9fe770 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -23,7 +23,7 @@ if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTI docker push pythondiscord/bot:latest echo "Deploying container" - curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK + curl -H "token: $1" $2 else echo "Skipping deploy" fi \ No newline at end of file -- cgit v1.2.3 From 497eef8bf66dd19c69c0df963c7db62961aff64c Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 14:02:22 +0000 Subject: Azure: This should be all, I hope. --- scripts/deploy-azure.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 8cb9fe770..6b3dea508 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -1,5 +1,7 @@ #!/bin/bash +cd .. + # Build and deploy on master branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) -- cgit v1.2.3 From 715d1e32eda88c763b5ba411bcbdf16f97b8296a Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 19:18:38 +0000 Subject: Attempt to make pipenv faster --- docker/bot.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index 4713e1f0e..ec2636423 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -8,7 +8,7 @@ ENV PIPENV_HIDE_EMOJIS=1 COPY . /bot WORKDIR /bot -RUN pipenv install --deploy --system +RUN pipenv sync --deploy --system ENTRYPOINT ["/sbin/tini", "--"] CMD ["python", "-m", "bot"] -- cgit v1.2.3 From f137b14a7d0026e9d653af126b77e1499bbb0173 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 19:25:54 +0000 Subject: Sync doesn't have --deploy --- docker/bot.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index ec2636423..362a19617 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -8,7 +8,7 @@ ENV PIPENV_HIDE_EMOJIS=1 COPY . /bot WORKDIR /bot -RUN pipenv sync --deploy --system +RUN pipenv sync --system ENTRYPOINT ["/sbin/tini", "--"] CMD ["python", "-m", "bot"] -- cgit v1.2.3 From 7787111068fb50159be1a93865133a21df2ab916 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 19:33:19 +0000 Subject: Pipenv: Back to install, add PIPENV_SKIP_LOCK --- docker/bot.Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index 362a19617..b85550e67 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -4,11 +4,12 @@ ENV PIPENV_VENV_IN_PROJECT=1 ENV PIPENV_IGNORE_VIRTUALENVS=1 ENV PIPENV_NOSPIN=1 ENV PIPENV_HIDE_EMOJIS=1 +ENV PIPENV_SKIP_LOCK=1 COPY . /bot WORKDIR /bot -RUN pipenv sync --system +RUN pipenv install --deploy --system ENTRYPOINT ["/sbin/tini", "--"] CMD ["python", "-m", "bot"] -- cgit v1.2.3 From 45f3278f362234dea42bf6fbbe744f74da493910 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 16 Nov 2018 12:12:31 -0800 Subject: big brother: re-use header if user and channel match previous message The header will be re-sent if the configured limit is exceeded. --- bot/cogs/bigbrother.py | 34 ++++++++++++++++++++++++++-------- bot/constants.py | 1 + config-default.yml | 1 + 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index f8bd4e0b5..d3e829d6a 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -26,6 +26,7 @@ class BigBrother: self.bot = bot self.watched_users = {} # { user_id: log_channel_id } 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.bot.loop.create_task(self.get_watched_users()) @@ -106,17 +107,12 @@ class BigBrother: for user_id, queues in channel_queues.items(): for _, queue in queues.items(): channel = self.watched_users[user_id] - - if queue: - # Send a header embed before sending all messages in the queue. - msg = queue[0] - embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") - embed.set_author(name=msg.author.nick or msg.author.name, icon_url=msg.author.avatar_url) - await channel.send(embed=embed) - while queue: msg = queue.popleft() log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") + + self.last_log[2] += 1 # Increment message count. + await self.send_header(msg, channel) await self.log_message(msg, channel) if self.channel_queues: @@ -126,6 +122,28 @@ class BigBrother: log.trace("Done consuming messages.") self.consuming = False + async def send_header(self, message: Message, destination: TextChannel): + """ + Sends a log message header to the given channel. + + A header is only sent if the user or channel are different than the previous, or if the configured message + limit for a single header has been exceeded. + + :param message: the first message in the queue + :param destination: the channel in which to send the header + """ + + last_user, last_channel, msg_count = self.last_log + limit = BigBrotherConfig.header_message_limit + + # 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: + 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) + await destination.send(embed=embed) + @staticmethod async def log_message(message: Message, destination: TextChannel): """ diff --git a/bot/constants.py b/bot/constants.py index 266bd5eee..57ce87ea1 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -452,6 +452,7 @@ class BigBrother(metaclass=YAMLGetter): section = 'big_brother' log_delay: int + header_message_limit: int # Debug mode diff --git a/config-default.yml b/config-default.yml index 3598caa45..e0f5fb235 100644 --- a/config-default.yml +++ b/config-default.yml @@ -319,6 +319,7 @@ wolfram: big_brother: log_delay: 15 + header_message_limit: 15 config: -- cgit v1.2.3 From 36ca5e81ba46924f5b18a68e89d977019af4ac27 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Fri, 16 Nov 2018 21:55:50 +0000 Subject: Skipping the lock operation makes pipenv slower somehow. Don't ask me why. --- docker/bot.Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index b85550e67..4713e1f0e 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -4,7 +4,6 @@ ENV PIPENV_VENV_IN_PROJECT=1 ENV PIPENV_IGNORE_VIRTUALENVS=1 ENV PIPENV_NOSPIN=1 ENV PIPENV_HIDE_EMOJIS=1 -ENV PIPENV_SKIP_LOCK=1 COPY . /bot WORKDIR /bot -- cgit v1.2.3 From 6853c55b9fb07d6c2ec0261bff647db8cb4cd655 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sat, 17 Nov 2018 00:08:35 +0000 Subject: Reitz sez: Try --sequential --- docker/bot.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index 4713e1f0e..d4968fbfa 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -8,7 +8,7 @@ ENV PIPENV_HIDE_EMOJIS=1 COPY . /bot WORKDIR /bot -RUN pipenv install --deploy --system +RUN pipenv install --deploy --system --sequential ENTRYPOINT ["/sbin/tini", "--"] CMD ["python", "-m", "bot"] -- cgit v1.2.3 From 58ddcec4f722ea8c50641a62db257d2a24d2ac1f Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sat, 17 Nov 2018 00:18:33 +0000 Subject: Send users a message when they're given an infraction. --- bot/cogs/moderation.py | 191 ++++++++++++++++++++++++++++++++++++++++++++--- bot/cogs/superstarify.py | 22 +++++- bot/constants.py | 1 + config-default.yml | 1 + 4 files changed, 203 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 2ab59f12d..4afeeb768 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,10 +1,12 @@ import asyncio import logging import textwrap -import typing +from typing import Union from aiohttp import ClientError -from discord import Colour, Embed, Guild, Member, Object, User +from discord import ( + Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User +) from discord.ext.commands import ( BadArgument, BadUnionArgument, Bot, Context, command, group ) @@ -15,12 +17,17 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs from bot.converters import InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator -from bot.utils.scheduling import Scheduler +from bot.utils.scheduling import Scheduler, create_task from bot.utils.time import parse_rfc1123, wait_until log = logging.getLogger(__name__) MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator +INFRACTION_ICONS = { + "Mute": Icons.user_mute, + "Kick": Icons.sign_out, + "Ban": Icons.user_ban +} def proxy_user(user_id: str) -> Object: @@ -66,13 +73,19 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="warn") - async def warn(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): + async def warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a warning infraction in the database for a user. :param user: accepts user mention, ID, etc. :param reason: The reason for the warning. """ + await self.notify_infraction( + user=user, + infr_type="Warning", + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -110,6 +123,12 @@ class Moderation(Scheduler): :param reason: The reason for the kick. """ + await self.notify_infraction( + user=user, + infr_type="Kick", + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -156,13 +175,20 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="ban") - async def ban(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): + async def ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. :param reason: The reason for the ban. """ + await self.notify_infraction( + user=user, + infr_type="Ban", + duration="Permanent", + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -217,6 +243,13 @@ class Moderation(Scheduler): :param reason: The reason for the mute. """ + await self.notify_infraction( + user=user, + infr_type="Mute", + duration="Permanent", + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -275,6 +308,13 @@ class Moderation(Scheduler): :param reason: The reason for the temporary mute. """ + await self.notify_infraction( + user=user, + infr_type="Mute", + duration=duration, + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -330,7 +370,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="tempban") - async def tempban(self, ctx: Context, user: typing.Union[User, proxy_user], duration: str, *, reason: str = None): + async def tempban(self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None): """ Create a temporary ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -338,6 +378,13 @@ class Moderation(Scheduler): :param reason: The reason for the temporary ban. """ + await self.notify_infraction( + user=user, + infr_type="Ban", + duration=duration, + reason=reason + ) + try: response = await self.bot.http_session.post( URLs.site_infractions, @@ -398,7 +445,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note']) - async def shadow_warn(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): + async def shadow_warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a warning infraction in the database for a user. :param user: accepts user mention, ID, etc. @@ -490,7 +537,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: typing.Union[User, proxy_user], *, reason: str = None): + async def shadow_ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None): """ Create a permanent ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -668,7 +715,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( - self, ctx: Context, user: typing.Union[User, proxy_user], duration: str, *, reason: str = None + self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None ): """ Create a temporary ban infraction in the database for a user. @@ -782,6 +829,13 @@ class Moderation(Scheduler): Intended expiry: {infraction_object['expires_at']} """) ) + + await self.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) except Exception: log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") @@ -789,7 +843,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="unban") - async def unban(self, ctx: Context, user: typing.Union[User, proxy_user]): + async def unban(self, ctx: Context, user: Union[User, proxy_user]): """ Deactivates the active ban infraction for a user. :param user: Accepts user mention, ID, etc. @@ -1026,7 +1080,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: typing.Union[User, proxy_user]): + async def search_user(self, ctx: Context, user: Union[User, proxy_user]): """ Search for infractions by member. """ @@ -1102,6 +1156,38 @@ class Moderation(Scheduler): max_size=1000 ) + # endregion + # region: Utility functions + + def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): + """ + Schedules a task to expire a temporary infraction. + :param loop: the asyncio event loop + :param infraction_object: the infraction object to expire at the end of the task + """ + + infraction_id = infraction_object["id"] + if infraction_id in self.scheduled_tasks: + return + + task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object)) + + self.scheduled_tasks[infraction_id] = task + + def cancel_expiration(self, infraction_id: str): + """ + Un-schedules a task set to expire a temporary infraction. + :param infraction_id: the ID of the infraction in question + """ + + task = self.scheduled_tasks.get(infraction_id) + if task is None: + log.warning(f"Failed to unschedule {infraction_id}: no task found.") + return + task.cancel() + log.debug(f"Unscheduled {infraction_id}.") + del self.scheduled_tasks[infraction_id] + async def _scheduled_task(self, infraction_object: dict): """ A co-routine which marks an infraction as expired after the delay from the time of scheduling @@ -1121,6 +1207,16 @@ class Moderation(Scheduler): self.cancel_task(infraction_object["id"]) + # Notify the user that they've been unmuted. + user_id = int(infraction_object["user"]["user_id"]) + guild = self.bot.get_guild(constants.Guild.id) + await self.notify_pardon( + user=guild.get_member(user_id), + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + async def _deactivate_infraction(self, infraction_object): """ A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or @@ -1177,6 +1273,79 @@ class Moderation(Scheduler): return lines.strip() + async def notify_infraction( + self, user: Union[User, Member], infr_type: str, duration: str = None, reason: str = None + ): + """ + Notify a user of their fresh infraction :) + + :param user: The user to send the message to. + :param infr_type: The type of infraction, as a string. + :param duration: The duration of the infraction. + :param reason: The reason for the infraction. + """ + + if duration is None: + duration = "N/A" + + if reason is None: + reason = "No reason provided." + + embed = Embed( + description=textwrap.dedent(f""" + **Type:** {infr_type} + **Duration:** {duration} + **Reason:** {reason} + """), + colour=Colour(Colours.soft_red) + ) + + icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed) + embed.set_author(name="Infraction Information", icon_url=icon_url) + embed.set_footer(text=f"Please review our rules over at https://pythondiscord.com/about/rules") + + await self.send_private_embed(user, embed) + + async def notify_pardon( + self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified + ): + """ + Notify a user that an infraction has been lifted. + + :param user: The user to send the message to. + :param title: The title of the embed. + :param content: The content of the embed. + :param icon_url: URL for the title icon. + """ + + embed = Embed( + description=content, + colour=Colour(Colours.soft_green) + ) + + embed.set_author(name=title, icon_url=icon_url) + + await self.send_private_embed(user, embed) + + async def send_private_embed(self, user: Union[User, Member], embed: Embed): + """ + A helper method for sending an embed to a user's DMs. + + :param user: The user to send the embed to. + :param embed: The embed to send. + """ + + # sometimes `user` is a `discord.Object`, so let's make it a proper user. + user = await self.bot.get_user_info(user.id) + + try: + await user.send(embed=embed) + except (HTTPException, Forbidden): + log.debug( + f"Infraction-related information could not be sent to user {user} ({user.id}). " + "They've probably just disabled private messages." + ) + # endregion async def __error(self, ctx, error): diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py index e1cfcc184..75d42c76b 100644 --- a/bot/cogs/superstarify.py +++ b/bot/cogs/superstarify.py @@ -5,6 +5,7 @@ from discord import Colour, Embed, Member from discord.errors import Forbidden from discord.ext.commands import Bot, Context, command +from bot.cogs.moderation import Moderation from bot.constants import ( Channels, Keys, NEGATIVE_REPLIES, POSITIVE_REPLIES, @@ -14,6 +15,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" class Superstarify: @@ -25,6 +27,10 @@ class Superstarify: self.bot = bot self.headers = {"X-API-KEY": Keys.site_api} + @property + def moderation(self) -> Moderation: + return self.bot.get_cog("Moderation") + async def on_member_update(self, before, after): """ This event will trigger when someone changes their name. @@ -133,7 +139,7 @@ class Superstarify: f"Your new nickname will be **{forced_nick}**.\n\n" f"You will be unable to change your nickname until \n**{end_time}**.\n\n" "If you're confused by this, please read our " - "[official nickname policy](https://pythondiscord.com/about/rules#nickname-policy)." + f"[official nickname policy]({NICKNAME_POLICY_URL})." ) embed.set_image(url=image_url) @@ -146,6 +152,13 @@ class Superstarify: f"They will not be able to change their nickname again until **{end_time}**" ) + await self.moderation.notify_infraction( + user=member, + infr_type="Superstarify", + duration=duration, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + # Change the nick and return the embed log.debug("Changing the users nickname and sending the embed.") await member.edit(nick=forced_nick) @@ -186,6 +199,13 @@ class Superstarify: f"{response}" ) + else: + await self.moderation.notify_pardon( + user=member, + title="You are no longer superstarified.", + content="You may now change your nickname on the server." + ) + log.debug(f"{member.display_name} was successfully released from superstar-prison.") await ctx.send(embed=embed) diff --git a/bot/constants.py b/bot/constants.py index 57ce87ea1..5e7927ed9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -290,6 +290,7 @@ class Icons(metaclass=YAMLGetter): user_mute: str user_unmute: str + user_verified: str pencil: str diff --git a/config-default.yml b/config-default.yml index e0f5fb235..41383a6ae 100644 --- a/config-default.yml +++ b/config-default.yml @@ -70,6 +70,7 @@ style: user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" -- cgit v1.2.3 From 96cb3f033486a8bc04c952b28ef00f13561688ee Mon Sep 17 00:00:00 2001 From: scragly Date: Sat, 17 Nov 2018 14:32:29 +1000 Subject: Add aliases, prefix to cmd sig, remove from subcommands --- bot/cogs/help.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 144286c56..d30ff0dfb 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -355,10 +355,18 @@ class HelpSession: # Use LinePaginator to restrict embed line height paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) + prefix = constants.Bot.prefix + # show signature if query is a command if isinstance(self.query, commands.Command): signature = self._get_command_params(self.query) - paginator.add_line(f'**```{signature}```**') + parent = self.query.full_parent_name + ' ' if self.query.parent else '' + paginator.add_line(f'**```{prefix}{parent}{signature}```**') + + # show command aliases + aliases = ', '.join(f'`{a}`' for a in self.query.aliases) + if aliases: + paginator.add_line(f'**Can also use:** {aliases}\n') if not await self.query.can_run(self._ctx): paginator.add_line('***You cannot run this command.***\n') @@ -392,6 +400,9 @@ class HelpSession: elif isinstance(self.query, commands.Command): grouped = (('**Subcommands:**', self.query.commands),) + # don't show prefix for subcommands + prefix = '' + # otherwise sort and organise all commands into categories else: cat_sort = sorted(filtered, key=self._category_key) @@ -424,7 +435,6 @@ class HelpSession: strikeout = '~~' signature = self._get_command_params(command) - prefix = constants.Bot.prefix info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" # handle if the command has no docstring -- cgit v1.2.3 From 4aca6a16d922119c0bae738a56b01981b6025930 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sat, 17 Nov 2018 17:21:35 +0000 Subject: Clean up docker images --- docker/base.Dockerfile | 11 ----------- docker/bot.Dockerfile | 7 +++++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index de2c68c13..a1ec0866e 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -9,19 +9,8 @@ RUN apk add --update libxml2 libxml2-dev libxslt-dev RUN apk add --update zlib-dev RUN apk add --update freetype-dev -RUN pip install pipenv - -RUN mkdir /bot -COPY Pipfile /bot -COPY Pipfile.lock /bot -WORKDIR /bot - ENV LIBRARY_PATH=/lib:/usr/lib ENV PIPENV_VENV_IN_PROJECT=1 ENV PIPENV_IGNORE_VIRTUALENVS=1 ENV PIPENV_NOSPIN=1 ENV PIPENV_HIDE_EMOJIS=1 - -RUN pipenv install --deploy --system - -# usage: FROM pythondiscord/bot-base:latest diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index d4968fbfa..5a07a612b 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -5,10 +5,13 @@ ENV PIPENV_IGNORE_VIRTUALENVS=1 ENV PIPENV_NOSPIN=1 ENV PIPENV_HIDE_EMOJIS=1 +RUN pip install -U pipenv + +RUN mkdir -p /bot COPY . /bot WORKDIR /bot -RUN pipenv install --deploy --system --sequential +RUN pipenv install --deploy ENTRYPOINT ["/sbin/tini", "--"] -CMD ["python", "-m", "bot"] +CMD ["pipenv", "run", "start"] -- cgit v1.2.3 From 7925667bf3797767bbef2bb86b7cd4df81da29a9 Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sun, 18 Nov 2018 00:56:58 +0000 Subject: Azure: Fix env vars in YAML --- azure-pipelines.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fe7486a5c..bc523fb31 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,11 +1,10 @@ # https://aka.ms/yaml variables: - ENV LIBRARY_PATH: /lib:/usr/lib - ENV PIPENV_HIDE_EMOJIS: 1 - ENV PIPENV_IGNORE_VIRTUALENVS: 1 - ENV PIPENV_NOSPIN: 1 - ENV PIPENV_VENV_IN_PROJECT: 1 + PIPENV_HIDE_EMOJIS: 1 + PIPENV_IGNORE_VIRTUALENVS: 1 + PIPENV_NOSPIN: 1 + PIPENV_VENV_IN_PROJECT: 1 jobs: - job: test -- cgit v1.2.3 From 61c33ec3a4d123258f6cb67f6a6c218a3b8c7f49 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 18 Nov 2018 01:18:14 -0800 Subject: Disable All Commands Except !accept in Verification Channel (#197) * disable all commands except !accept in verification channel * replace add_check with __global special method Co-Authored-By: MarkKoz --- bot/cogs/verification.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 8d29a4bee..56fcd63eb 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -151,6 +151,17 @@ class Verification: f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications." ) + @staticmethod + def __global_check(ctx: Context): + """ + Block any command within the verification channel that is not !accept. + """ + + if ctx.channel.id == Channels.verification: + return ctx.command.name == "accept" + else: + return True + def setup(bot): bot.add_cog(Verification(bot)) -- cgit v1.2.3 From 09d3647c995a8c50ed3370ada11d3771988df1be Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Sun, 18 Nov 2018 13:46:12 +0000 Subject: Add badges to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26d2c9e56..bfa9d3e42 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -Python Utility Bot -================== +# Python Utility Bot +[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot%20(Mainline))](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1) [![Discord](https://discordapp.com/api/guilds/267624335836053506/embed.png)](https://discord.gg/2B963hn) This project is a Discord bot specifically for use with the Python Discord server. It will provide numerous utilities -- cgit v1.2.3 From 521091b84ad74e43fa6d48a6ec46df5dfb55c6f1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Nov 2018 02:34:20 +1000 Subject: Fix cog reload ClientException bug (#195) --- bot/cogs/cogs.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index f090984dd..0a33b3de0 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -1,7 +1,7 @@ import logging import os -from discord import ClientException, Colour, Embed +from discord import Colour, Embed from discord.ext.commands import Bot, Context, group from bot.constants import ( @@ -77,10 +77,6 @@ class Cogs: if full_cog not in self.bot.extensions: try: self.bot.load_extension(full_cog) - except ClientException: - log.error(f"{ctx.author} requested we load the '{cog}' cog, " - "but that cog doesn't have a 'setup()' function.") - embed.description = f"Invalid cog: {cog}\n\nCog does not have a `setup()` function" except ImportError: log.error(f"{ctx.author} requested we load the '{cog}' cog, " f"but the cog module {full_cog} could not be found!") -- cgit v1.2.3 From 377946d7302eb5b2bd3c6cc83eabc3424591f6ac Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 19 Nov 2018 14:50:50 -0500 Subject: Superstarify modlog (#199) * Update superstarify mod log message * Fix syntax & lint issues Shame on me... * Fix reference to ModLog cog * Add user thumbnail to superstarify modlog * Trailing whitespace is the devil * Update dodgy superstarify alias --- bot/cogs/superstarify.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py index 75d42c76b..d630ef863 100644 --- a/bot/cogs/superstarify.py +++ b/bot/cogs/superstarify.py @@ -6,14 +6,14 @@ from discord.errors import Forbidden from discord.ext.commands import Bot, Context, command from bot.cogs.moderation import Moderation +from bot.cogs.modlog import ModLog from bot.constants import ( - Channels, Keys, + Icons, Keys, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles, URLs ) from bot.decorators import with_role - log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" @@ -31,6 +31,10 @@ class Superstarify: def moderation(self) -> Moderation: return self.bot.get_cog("Moderation") + @property + def modlog(self) -> ModLog: + return self.bot.get_cog("ModLog") + async def on_member_update(self, before, after): """ This event will trigger when someone changes their name. @@ -81,7 +85,7 @@ class Superstarify: "to DM them, and a discord.errors.Forbidden error was incurred." ) - @command(name='superstarify', aliases=('force_nick', 'ss')) + @command(name='superstarify', aliases=('force_nick', 'star')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): """ @@ -145,11 +149,18 @@ class Superstarify: # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log = self.bot.get_channel(Channels.modlog) - await mod_log.send( - f":middle_finger: {member.name}#{member.discriminator} (`{member.id}`) " - f"has been superstarified by **{ctx.author.name}**. Their new nickname is `{forced_nick}`. " - f"They will not be able to change their nickname again until **{end_time}**" + mod_log_message = ( + f"{member.name}#{member.discriminator} (`{member.id}`)\n\n" + f"Superstarified by **{ctx.author.name}**\n" + f"New nickname:`{forced_nick}`\n" + f"Superstardom ends: **{end_time}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Member Achieved Superstardom", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") ) await self.moderation.notify_infraction( -- cgit v1.2.3 From 44e41f3244d26a0a8331fda3b1b2e393f980b0b8 Mon Sep 17 00:00:00 2001 From: sco1 Date: Wed, 21 Nov 2018 07:24:41 -0500 Subject: Add note support for bb watch, moderation API call refactor (#201) * Add helper for moderation API calls See: #200 * Add missing short-circuit for infraction fail * Add bb watch note support See: 103 * Add keyword-only argument to bb watch To support a full reason string * Add empty duration to moderation API helper * Prepend bb watch to watch note string --- bot/cogs/bigbrother.py | 18 ++-- bot/cogs/moderation.py | 263 +++++------------------------------------------- bot/utils/moderation.py | 41 ++++++++ 3 files changed, 77 insertions(+), 245 deletions(-) create mode 100644 bot/utils/moderation.py diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index d3e829d6a..7964c81a8 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -11,6 +11,7 @@ from bot.constants import BigBrother as BigBrotherConfig, Channels, Emojis, Guil from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import messages +from bot.utils.moderation import post_infraction log = logging.getLogger(__name__) @@ -215,16 +216,14 @@ class BigBrother: @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, channel: TextChannel = None): + async def watch_command(self, ctx: Context, user: User, *, reason: str = None): """ - Relay messages sent by the given `user` in the given `channel`. - If `channel` is not specified, logs to the mod log channel. + Relay messages sent by the given `user` to the `#big-brother-logs` channel + + If a `reason` is specified, a note is added for `user` """ - if channel is not None: - channel_id = channel.id - else: - channel_id = Channels.big_brother_logs + channel_id = Channels.big_brother_logs post_data = { 'user_id': str(user.id), @@ -252,6 +251,11 @@ class BigBrother: reason = data.get('error_message', "no message provided") await ctx.send(f":x: the API returned an error: {reason}") + # Add a note (shadow warning) if a reason is specified + if reason: + reason = "bb watch: " + reason # Prepend for situational awareness + await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def unwatch_command(self, ctx: Context, user: User): diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 4afeeb768..71654ee1c 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -17,6 +17,7 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs from bot.converters import InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.moderation import post_infraction from bot.utils.scheduling import Scheduler, create_task from bot.utils.time import parse_rfc1123, wait_until @@ -86,25 +87,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "warning", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="warning", reason=reason) + if response_object is None: return if reason is None: @@ -129,25 +113,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "kick", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="kick", reason=reason) + if response_object is None: return self.mod_log.ignore(Event.member_remove, user.id) @@ -189,25 +156,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "ban", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="ban", reason=reason) + if response_object is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -250,25 +200,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "mute", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="mute", reason=reason) + if response_object is None: return # add the mute role @@ -315,26 +248,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "mute", - "reason": reason, - "duration": duration, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration) + if response_object is None: return self.mod_log.ignore(Event.member_update, user.id) @@ -385,26 +300,8 @@ class Moderation(Scheduler): reason=reason ) - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "ban", - "reason": reason, - "duration": duration, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id) - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration) + if response_object is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -452,26 +349,8 @@ class Moderation(Scheduler): :param reason: The reason for the warning. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "warning", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + if response_object is None: return if reason is None: @@ -490,26 +369,8 @@ class Moderation(Scheduler): :param reason: The reason for the kick. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "kick", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) + if response_object is None: return self.mod_log.ignore(Event.member_remove, user.id) @@ -544,26 +405,8 @@ class Moderation(Scheduler): :param reason: The reason for the ban. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "ban", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) + if response_object is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -599,26 +442,8 @@ class Moderation(Scheduler): :param reason: The reason for the mute. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "mute", - "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) + if response_object is None: return # add the mute role @@ -658,27 +483,8 @@ class Moderation(Scheduler): :param reason: The reason for the temporary mute. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "mute", - "reason": reason, - "duration": duration, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration, hidden=True) + if response_object is None: return self.mod_log.ignore(Event.member_update, user.id) @@ -724,27 +530,8 @@ class Moderation(Scheduler): :param reason: The reason for the temporary ban. """ - try: - response = await self.bot.http_session.post( - URLs.site_infractions, - headers=self.headers, - json={ - "type": "ban", - "reason": reason, - "duration": duration, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": True - } - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return - - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration, hidden=True) + if response_object is None: return self.mod_log.ignore(Event.member_ban, user.id) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py new file mode 100644 index 000000000..1b36b4118 --- /dev/null +++ b/bot/utils/moderation.py @@ -0,0 +1,41 @@ +import logging +from typing import Union + +from aiohttp import ClientError +from discord import Member, Object, User +from discord.ext.commands import Context + +from bot.constants import Keys, URLs + +log = logging.getLogger(__name__) + +HEADERS = {"X-API-KEY": Keys.site_api} + + +async def post_infraction( + ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False +): + try: + response = await ctx.bot.http_session.post( + URLs.site_infractions, + headers=HEADERS, + json={ + "type": type, + "reason": reason, + "duration": duration, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": hidden, + }, + ) + except ClientError: + log.exception("There was an error adding an infraction.") + await ctx.send(":x: There was an error adding the infraction.") + return + + response_object = await response.json() + if "error_code" in response_object: + await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") + return + + return response_object -- cgit v1.2.3 From 1fdab40f364416ae6d69afb14fb6658f591aa189 Mon Sep 17 00:00:00 2001 From: sco1 Date: Wed, 21 Nov 2018 18:20:51 -0500 Subject: Remove duration from JSON payload when not specified (#204) * Remove duration from JSON payload when not specified * Drop whitespace. --- bot/utils/moderation.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 1b36b4118..724b455bc 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -15,18 +15,22 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False ): + + payload = { + "type": type, + "reason": reason, + "user_id": str(user.id), + "actor_id": str(ctx.message.author.id), + "hidden": hidden + } + if duration: + payload['duration'] = duration + try: response = await ctx.bot.http_session.post( URLs.site_infractions, headers=HEADERS, - json={ - "type": type, - "reason": reason, - "duration": duration, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": hidden, - }, + json=payload ) except ClientError: log.exception("There was an error adding an infraction.") -- cgit v1.2.3 From 91be26a1a9ebb5dab07f38b9f6c9ab65b2da672e Mon Sep 17 00:00:00 2001 From: ByteCommander Date: Sun, 25 Nov 2018 01:42:55 +0100 Subject: Restore superstar nickname after member leaves and rejoins (#207) * Solve #205 by restoring superstar nicks on member join; prettier modlog * Switch footer to title in Infraction Info DM to get clickable links * Fix exceptions from message edit events in DMs * Improve readability as requested (multiline import and if statements) * Change continuation indents, as requested.... --- bot/cogs/moderation.py | 6 ++-- bot/cogs/modlog.py | 24 ++++++++++++---- bot/cogs/superstarify.py | 71 ++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 71654ee1c..6e958b912 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -29,6 +29,7 @@ INFRACTION_ICONS = { "Kick": Icons.sign_out, "Ban": Icons.user_ban } +RULES_URL = "https://pythondiscord.com/about/rules" def proxy_user(user_id: str) -> Object: @@ -1088,8 +1089,9 @@ class Moderation(Scheduler): ) icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed) - embed.set_author(name="Infraction Information", icon_url=icon_url) - embed.set_footer(text=f"Please review our rules over at https://pythondiscord.com/about/rules") + embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL) + embed.title = f"Please review our rules over at {RULES_URL}" + embed.url = RULES_URL await self.send_private_embed(user, embed) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 9c81661ba..1d1546d5b 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -10,12 +10,16 @@ from discord import ( CategoryChannel, Colour, Embed, File, Guild, Member, Message, NotFound, RawBulkMessageDeleteEvent, RawMessageDeleteEvent, RawMessageUpdateEvent, Role, - TextChannel, User, VoiceChannel) + TextChannel, User, VoiceChannel +) from discord.abc import GuildChannel from discord.ext.commands import Bot -from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles, URLs -from bot.constants import Guild as GuildConstant +from bot.constants import ( + Channels, Colours, Emojis, + Event, Guild as GuildConstant, Icons, + Keys, Roles, URLs +) from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -609,7 +613,12 @@ class ModLog: ) async def on_message_edit(self, before: Message, after: Message): - if before.guild.id != GuildConstant.id or before.channel.id in GuildConstant.ignored or before.author.bot: + if ( + not before.guild + or before.guild.id != GuildConstant.id + or before.channel.id in GuildConstant.ignored + or before.author.bot + ): return self._cached_edits.append(before.id) @@ -670,7 +679,12 @@ class ModLog: except NotFound: # Was deleted before we got the event return - if message.guild.id != GuildConstant.id or message.channel.id in GuildConstant.ignored or message.author.bot: + if ( + not message.guild + or message.guild.id != GuildConstant.id + or message.channel.id in GuildConstant.ignored + or message.author.bot + ): return await asyncio.sleep(1) # Wait here in case the normal event was fired diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py index d630ef863..84467bd8c 100644 --- a/bot/cogs/superstarify.py +++ b/bot/cogs/superstarify.py @@ -35,13 +35,12 @@ class Superstarify: def modlog(self) -> ModLog: return self.bot.get_cog("ModLog") - async def on_member_update(self, before, after): + async def on_member_update(self, before: Member, after: Member): """ This event will trigger when someone changes their name. At this point we will look up the user in our database and check whether they are allowed to change their names, or if they are in superstar-prison. If they are not allowed, we will change it back. - :return: """ if before.display_name == after.display_name: @@ -85,6 +84,64 @@ class Superstarify: "to DM them, and a discord.errors.Forbidden error was incurred." ) + async def on_member_join(self, member: Member): + """ + This event will trigger when someone (re)joins the server. + At this point we will look up the user in our database and check + whether they are in superstar-prison. If so, we will change their name + back to the forced nickname. + """ + + response = await self.bot.http_session.get( + URLs.site_superstarify_api, + headers=self.headers, + params={"user_id": str(member.id)} + ) + + response = await response.json() + + if response and response.get("end_timestamp") and not response.get("error_code"): + forced_nick = response.get("forced_nick") + end_timestamp = response.get("end_timestamp") + log.debug( + f"{member.name} rejoined but is currently in superstar-prison. " + f"Changing the nick back to {forced_nick}." + ) + + await member.edit(nick=forced_nick) + try: + await member.send( + "You have left and rejoined the **Python Discord** server, effectively resetting " + f"your nickname from **{forced_nick}** to **{member.name}**, " + "but as you are currently in superstar-prison, you do not have permission to do so. " + "Therefore your nickname was automatically changed back. You will be allowed to " + "change your nickname again at the following time:\n\n" + f"**{end_timestamp}**." + ) + except Forbidden: + log.warning( + "The user left and rejoined the server while in superstar-prison. " + "This led to the bot trying to DM the user to let them know their name was restored, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified member potentially tried to escape the prison.\n" + f"Restored enforced nickname: `{forced_nick}`\n" + f"Superstardom ends: **{end_timestamp}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Superstar member rejoined server", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + @command(name='superstarify', aliases=('force_nick', 'star')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): @@ -136,10 +193,11 @@ class Superstarify: forced_nick = response.get('forced_nick') end_time = response.get("end_timestamp") image_url = response.get("image_url") + old_nick = member.display_name embed.title = "Congratulations!" embed.description = ( - f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your previous nickname, **{old_nick}**, was so bad that we have decided to change it. " f"Your new nickname will be **{forced_nick}**.\n\n" f"You will be unable to change your nickname until \n**{end_time}**.\n\n" "If you're confused by this, please read our " @@ -150,9 +208,10 @@ class Superstarify: # Log to the mod_log channel log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") mod_log_message = ( - f"{member.name}#{member.discriminator} (`{member.id}`)\n\n" + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" f"Superstarified by **{ctx.author.name}**\n" - f"New nickname:`{forced_nick}`\n" + f"Old nickname: `{old_nick}`\n" + f"New nickname: `{forced_nick}`\n" f"Superstardom ends: **{end_time}**" ) await self.modlog.send_log_message( @@ -175,7 +234,7 @@ class Superstarify: await member.edit(nick=forced_nick) await ctx.send(embed=embed) - @command(name='unsuperstarify', aliases=('release_nick', 'uss')) + @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def unsuperstarify(self, ctx: Context, member: Member): """ -- cgit v1.2.3 From 36f6952e635c304e7495814530864c748608270d Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 26 Nov 2018 02:42:55 -0800 Subject: Update Dependencies (#196) * update & clean up dependencies * fix lint errors * move requests to non-dev packages * Empty commit to fix CI * switch discord.py to a git dependency * remove PIPENV_VENV_IN_PROJECT * make pipenv install verbose * specify checkout directory for editable dependencies * exclude cache directory from linting --- Pipfile | 12 +- Pipfile.lock | 635 +++++++++++++++++++++++++------------------ azure-pipelines.yml | 16 +- bot/cogs/alias.py | 2 +- bot/cogs/bot.py | 6 +- bot/cogs/eval.py | 9 +- bot/cogs/events.py | 8 +- bot/cogs/filtering.py | 2 +- bot/cogs/tags.py | 4 +- bot/cogs/wolfram.py | 6 +- bot/pagination.py | 12 +- bot/utils/__init__.py | 6 +- bot/utils/messages.py | 10 +- bot/utils/snakes/hatching.py | 28 +- tox.ini | 2 +- 15 files changed, 431 insertions(+), 327 deletions(-) diff --git a/Pipfile b/Pipfile index d3d315e6e..179b317df 100644 --- a/Pipfile +++ b/Pipfile @@ -4,27 +4,24 @@ verify_ssl = true name = "pypi" [packages] -discord = {file = "https://github.com/Rapptz/discord.py/archive/rewrite.zip", egg = "discord.py[voice]"} +discord-py = {git = "https://github.com/Rapptz/discord.py.git", extras = ["voice"], ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb", editable = true} dulwich = "*" -multidict = "*" -sympy = "*" aiodns = "*" logmatic-python = "*" -aiohttp = "<2.3.0,>=2.0.0" -websockets = ">=4.0,<5.0" +aiohttp = "*" sphinx = "*" markdownify = "*" lxml = "*" pyyaml = "*" -yarl = "==1.1.1" fuzzywuzzy = "*" pillow = "*" aio-pika = "*" python-dateutil = "*" deepdiff = "*" +requests = "*" [dev-packages] -"flake8" = "*" +"flake8" = ">=3.6" "flake8-bugbear" = "*" "flake8-import-order" = "*" "flake8-tidy-imports" = "*" @@ -32,7 +29,6 @@ deepdiff = "*" "flake8-string-format" = "*" safety = "*" dodgy = "*" -requests = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 8b43235bb..506b17065 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9c22a342245c638b196b519a8afb8a2c66410d76283746cfdd89f19ff7dce94c" + "sha256": "79a3c633f145dbf93ba5b2460d3f49346495328af7302e59be326e9324785cf3" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c19c38155f4972f6a9f3f0f1095ce261bfb4e8b89553ead240486593aafd9431", - "sha256:d41748994e2f809c440a04a1eb809aaae00691caa8e2dab7376d640131754aa4" + "sha256:6438e72963e459552f196a07a081a5f6dc54d42a474292b8497bd4a59554fc85", + "sha256:dc15b451dca6d2b1c504ab353e3f2fe7e7e252fdb1c219261b5412e1cafbc72d" ], "index": "pypi", - "version": "==3.0.1" + "version": "==4.6.3" }, "aiodns": { "hashes": [ @@ -34,36 +34,51 @@ }, "aiohttp": { "hashes": [ - "sha256:129d83dd067760cec3cfd4456b5c6d7ac29f2c639d856884568fd539bed5a51f", - "sha256:33c62afd115c456b0cf1e890fe6753055effe0f31a28321efd4f787378d6f4ab", - "sha256:666756e1d4cf161ed1486b82f65fdd386ac07dd20fb10f025abf4be54be12746", - "sha256:9705ded5a0faa25c8f14c6afb7044002d66c9120ed7eadb4aa9ca4aad32bd00c", - "sha256:af5bfdd164256118a0a306b3f7046e63207d1f8cba73a67dcc0bd858dcfcd3bc", - "sha256:b80f44b99fa3c9b4530fcfa324a99b84843043c35b084e0b653566049974435d", - "sha256:c67e105ec74b85c8cb666b6877569dee6f55b9548f982983b9bee80b3d47e6f3", - "sha256:d15c6658de5b7783c2538407278fa062b079a46d5f814a133ae0f09bbb2cfbc4", - "sha256:d611ebd1ef48498210b65486306e065fde031040a1f3c455ca1b6baa7bf32ad3", - "sha256:dcc7e4dcec6b0012537b9f8a0726f8b111188894ab0f924b680d40b13d3298a0", - "sha256:de8ef106e130b94ca143fdfc6f27cda1d8ba439462542377738af4d99d9f5dd2", - "sha256:eb6f1405b607fff7e44168e3ceb5d3c8a8c5a2d3effe0a27f843b16ec047a6d7", - "sha256:f0e2ac69cb709367400008cebccd5d48161dd146096a009a632a132babe5714c" - ], - "index": "pypi", - "version": "==2.2.5" + "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b", + "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08", + "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd", + "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac", + "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650", + "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa", + "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95", + "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330", + "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc", + "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b", + "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de", + "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4", + "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7", + "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b", + "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8", + "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd", + "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2", + "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698", + "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95", + "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6", + "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0", + "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07" + ], + "version": "==3.4.4" }, "alabaster": { "hashes": [ - "sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456", - "sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7" + "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", + "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" ], - "version": "==0.7.11" + "version": "==0.7.12" }, "async-timeout": { "hashes": [ - "sha256:474d4bc64cee20603e225eb1ece15e248962958b45a3648a9f5cc29e827a610c", - "sha256:b3c0ddc416736619bd4a95ca31de8da6920c3b9a140c64dbef2b2fa7bf521287" + "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", + "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" + ], + "version": "==3.0.1" + }, + "attrs": { + "hashes": [ + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" ], - "version": "==3.0.0" + "version": "==18.2.0" }, "babel": { "hashes": [ @@ -74,20 +89,55 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:2545357585a6cc7d050d3c43a86eba2c0b91b9e7ac8a3965e64a6ead6a1a9a3d", - "sha256:272081ad78c5495ba67083a0e50920163701fa6fe67fbb5eefeb21b5dd88c40b", - "sha256:4ddc90ad88bccc005a71d8ef32f7b1cd8f935475cd561c4122b2f87de45d28ab", - "sha256:5a3d659840960a4107047b6328d6d4cdaaee69939bf11adc07466a1856c99a80", - "sha256:bd43a3b26d2886acd63070c43da821b60dea603eb6d45bab0294aac6129adbfa" + "sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57", + "sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10", + "sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938" ], - "version": "==4.6.1" + "version": "==4.6.3" }, "certifi": { "hashes": [ - "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", - "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" - ], - "version": "==2018.4.16" + "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", + "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" + ], + "version": "==2018.10.15" + }, + "cffi": { + "hashes": [ + "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", + "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", + "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", + "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", + "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", + "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", + "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", + "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", + "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", + "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", + "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", + "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", + "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", + "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", + "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", + "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", + "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", + "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", + "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", + "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", + "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", + "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", + "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", + "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", + "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", + "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", + "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", + "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", + "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", + "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", + "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", + "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" + ], + "version": "==1.11.5" }, "chardet": { "hashes": [ @@ -105,9 +155,13 @@ "index": "pypi", "version": "==3.3.0" }, - "discord": { - "egg": "discord.py[voice]", - "file": "https://github.com/Rapptz/discord.py/archive/rewrite.zip" + "discord-py": { + "editable": true, + "extras": [ + "voice" + ], + "git": "https://github.com/Rapptz/discord.py.git", + "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb" }, "docutils": { "hashes": [ @@ -119,18 +173,18 @@ }, "dulwich": { "hashes": [ - "sha256:34f99e575fe1f1e89cca92cec1ddd50b4991199cb00609203b28df9eb83ce259" + "sha256:5e1e39555f594939a8aff1ca08b3bdf6c7efd4b941c2850760983a0197240974" ], "index": "pypi", - "version": "==0.19.5" + "version": "==0.19.9" }, "fuzzywuzzy": { "hashes": [ - "sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e", - "sha256:ecf490216fb4d76b558a03042ff8f45a8782f17326caca1384d834cbaa2c7e6f" + "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254", + "sha256:6f49de47db00e1c71d40ad16da42284ac357936fa9b66bea1df63fed07122d62" ], "index": "pypi", - "version": "==0.16.0" + "version": "==0.17.0" }, "idna": { "hashes": [ @@ -139,12 +193,19 @@ ], "version": "==2.7" }, + "idna-ssl": { + "hashes": [ + "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c" + ], + "markers": "python_version < '3.7'", + "version": "==1.1.0" + }, "imagesize": { "hashes": [ - "sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18", - "sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315" + "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", + "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" ], - "version": "==1.0.0" + "version": "==1.1.0" }, "jinja2": { "hashes": [ @@ -155,9 +216,11 @@ }, "jsonpickle": { "hashes": [ - "sha256:545b3bee0d65e1abb4baa1818edcc9ec239aa9f2ffbfde8084d71c056180054f" + "sha256:8b6212f1155f43ce67fa945efae6d010ed059f3ca5ed377aa070e5903d45b722", + "sha256:d43ede55b3d9b5524a8e11566ea0b11c9c8109116ef6a509a1b619d2041e7397", + "sha256:ed4adf0d14564c56023862eabfac211cf01211a20c5271896c8ab6f80c68086c" ], - "version": "==0.9.6" + "version": "==1.0" }, "logmatic-python": { "hashes": [ @@ -168,35 +231,39 @@ }, "lxml": { "hashes": [ - "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d", - "sha256:0b18890aa5730f9d847bc5469e8820f782d72af9985a15a7552109a86b01c113", - "sha256:21f427945f612ac75576632b1bb8c21233393c961f2da890d7be3927a4b6085f", - "sha256:24cf6f622a4d49851afcf63ac4f0f3419754d4e98a7a548ab48dd03c635d9bd3", - "sha256:2dc6705486b8abee1af9e2a3761e30a3cb19e8276f20ca7e137ee6611b93707c", - "sha256:2e43b2e5b7d2b9abe6e0301eef2c2c122ab45152b968910eae68bdee2c4cfae0", - "sha256:329a6d8b6d36f7d6f8b6c6a1db3b2c40f7e30a19d3caf62023c9d6a677c1b5e1", - "sha256:423cde55430a348bda6f1021faad7235c2a95a6bdb749e34824e5758f755817a", - "sha256:4651ea05939374cfb5fe87aab5271ed38c31ea47997e17ec3834b75b94bd9f15", - "sha256:4be3bbfb2968d7da6e5c2cd4104fc5ec1caf9c0794f6cae724da5a53b4d9f5a3", - "sha256:622f7e40faef13d232fb52003661f2764ce6cdef3edb0a59af7c1559e4cc36d1", - "sha256:664dfd4384d886b239ef0d7ee5cff2b463831079d250528b10e394a322f141f9", - "sha256:697c0f58ac637b11991a1bc92e07c34da4a72e2eda34d317d2c1c47e2f24c1b3", - "sha256:6ec908b4c8a4faa7fe1a0080768e2ce733f268b287dfefb723273fb34141475f", - "sha256:7ec3fe795582b75bb49bb1685ffc462dbe38d74312dac07ce386671a28b5316b", - "sha256:8c39babd923c431dcf1e5874c0f778d3a5c745a62c3a9b6bd755efd489ee8a1d", - "sha256:949ca5bc56d6cb73d956f4862ba06ad3c5d2808eac76304284f53ae0c8b2334a", - "sha256:9f0daddeefb0791a600e6195441910bdf01eac470be596b9467e6122b51239a6", - "sha256:a359893b01c30e949eae0e8a85671a593364c9f0b8162afe0cb97317af0953bf", - "sha256:ad5d5d8efed59e6b1d4c50c1eac59fb6ecec91b2073676af1e15fc4d43e9b6c5", - "sha256:bc1a36f95a6b3667c09b34995fc3a46a82e4cf0dc3e7ab281e4c77b15bd7af05", - "sha256:be37b3f55b6d7d923f43bf74c356fc1878eb36e28505f38e198cb432c19c7b1a", - "sha256:c45bca5e544eb75f7500ffd730df72922eb878a2f0213b0dc5a5f357ded3a85d", - "sha256:ccee7ebbb4735ebc341d347fca9ee09f2fa6c0580528c1414bc4e1d31372835c", - "sha256:dc62c0840b2fc7753550b40405532a3e125c0d3761f34af948873393aa688160", - "sha256:f7d9d5aa1c7e54167f1a3cba36b5c52c7c540f30952c9bd7d9302a1eda318424" + "sha256:02bc220d61f46e9b9d5a53c361ef95e9f5e1d27171cd461dddb17677ae2289a5", + "sha256:22f253b542a342755f6cfc047fe4d3a296515cf9b542bc6e261af45a80b8caf6", + "sha256:2f31145c7ff665b330919bfa44aacd3a0211a76ca7e7b441039d2a0b0451e415", + "sha256:36720698c29e7a9626a0dc802ef8885f8f0239bfd1689628ecd459a061f2807f", + "sha256:438a1b0203545521f6616132bfe0f4bca86f8a401364008b30e2b26ec408ce85", + "sha256:4815892904c336bbaf73dafd54f45f69f4021c22b5bad7332176bbf4fb830568", + "sha256:5be031b0f15ad63910d8e5038b489d95a79929513b3634ad4babf77100602588", + "sha256:5c93ae37c3c588e829b037fdfbd64a6e40c901d3f93f7beed6d724c44829a3ad", + "sha256:60842230678674cdac4a1cf0f707ef12d75b9a4fc4a565add4f710b5fcf185d5", + "sha256:62939a8bb6758d1bf923aa1c13f0bcfa9bf5b2fc0f5fa917a6e25db5fe0cfa4e", + "sha256:75830c06a62fe7b8fe3bbb5f269f0b308f19f3949ac81cfd40062f47c1455faf", + "sha256:81992565b74332c7c1aff6a913a3e906771aa81c9d0c68c68113cffcae45bc53", + "sha256:8c892fb0ee52c594d9a7751c7d7356056a9682674b92cc1c4dc968ff0f30c52f", + "sha256:9d862e3cf4fc1f2837dedce9c42269c8c76d027e49820a548ac89fdcee1e361f", + "sha256:a623965c086a6e91bb703d4da62dabe59fe88888e82c4117d544e11fd74835d6", + "sha256:a7783ab7f6a508b0510490cef9f857b763d796ba7476d9703f89722928d1e113", + "sha256:aab09fbe8abfa3b9ce62aaf45aca2d28726b1b9ee44871dbe644050a2fff4940", + "sha256:abf181934ac3ef193832fb973fd7f6149b5c531903c2ec0f1220941d73eee601", + "sha256:ae07fa0c115733fce1e9da96a3ac3fa24801742ca17e917e0c79d63a01eeb843", + "sha256:b9c78242219f674ab645ec571c9a95d70f381319a23911941cd2358a8e0521cf", + "sha256:bccb267678b870d9782c3b44d0cefe3ba0e329f9af8c946d32bf3778e7a4f271", + "sha256:c4df4d27f4c93b2cef74579f00b1d3a31a929c7d8023f870c4b476f03a274db4", + "sha256:caf0e50b546bb60dfa99bb18dfa6748458a83131ecdceaf5c071d74907e7e78a", + "sha256:d3266bd3ac59ac4edcd5fa75165dee80b94a3e5c91049df5f7c057ccf097551c", + "sha256:db0d213987bcd4e6d41710fb4532b22315b0d8fb439ff901782234456556aed1", + "sha256:dbbd5cf7690a40a9f0a9325ab480d0fccf46d16b378eefc08e195d84299bfae1", + "sha256:e16e07a0ec3a75b5ee61f2b1003c35696738f937dc8148fbda9fe2147ccb6e61", + "sha256:e175a006725c7faadbe69e791877d09936c0ef2cf49d01b60a6c1efcb0e8be6f", + "sha256:edd9c13a97f6550f9da2236126bb51c092b3b1ce6187f2bd966533ad794bbb5e", + "sha256:fa39ea60d527fbdd94215b5e5552f1c6a912624521093f1384a491a8ad89ad8b" ], "index": "pypi", - "version": "==4.2.3" + "version": "==4.2.5" }, "markdownify": { "hashes": [ @@ -207,97 +274,113 @@ }, "markupsafe": { "hashes": [ - "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" - ], - "version": "==1.0" - }, - "mpmath": { - "hashes": [ - "sha256:04d14803b6875fe6d69e6dccea87d5ae5599802e4b1df7997bddd2024001050c" + "sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432", + "sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b", + "sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9", + "sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af", + "sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834", + "sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd", + "sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d", + "sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7", + "sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b", + "sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3", + "sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c", + "sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2", + "sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7", + "sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36", + "sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1", + "sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e", + "sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1", + "sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c", + "sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856", + "sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550", + "sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492", + "sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672", + "sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401", + "sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6", + "sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6", + "sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c", + "sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd", + "sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1" ], - "version": "==1.0.0" + "version": "==1.1.0" }, "multidict": { "hashes": [ - "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0", - "sha256:1d6e191965505652f194bc4c40270a842922685918a4f45e6936a6b15cc5816d", - "sha256:295961a6a88f1199e19968e15d9b42f3a191c89ec13034dbc212bf9c394c3c82", - "sha256:2be5af084de6c3b8e20d6421cb0346378a9c867dcf7c86030d6b0b550f9888e4", - "sha256:2eb99617c7a0e9f2b90b64bc1fb742611718618572747d6f3d6532b7b78755ab", - "sha256:4ba654c6b5ad1ae4a4d792abeb695b29ce981bb0f157a41d0fd227b385f2bef0", - "sha256:5ba766433c30d703f6b2c17eb0b6826c6f898e5f58d89373e235f07764952314", - "sha256:a59d58ee85b11f337b54933e8d758b2356fcdcc493248e004c9c5e5d11eedbe4", - "sha256:a6e35d28900cf87bcc11e6ca9e474db0099b78f0be0a41d95bef02d49101b5b2", - "sha256:b4df7ca9c01018a51e43937eaa41f2f5dce17a6382fda0086403bcb1f5c2cf8e", - "sha256:bbd5a6bffd3ba8bfe75b16b5e28af15265538e8be011b0b9fddc7d86a453fd4a", - "sha256:d870f399fcd58a1889e93008762a3b9a27cf7ea512818fc6e689f59495648355", - "sha256:e9404e2e19e901121c3c5c6cffd5a8ae0d1d67919c970e3b3262231175713068" - ], - "index": "pypi", - "version": "==4.3.1" + "sha256:013eb6591ab95173fd3deb7667d80951abac80100335b3e97b5fa778c1bb4b91", + "sha256:0bffbbbb48db35f57dfb4733e943ac8178efb31aab5601cb7b303ee228ce96af", + "sha256:1a34aab1dfba492407c757532f665ba3282ec4a40b0d2f678bda828ef422ebb7", + "sha256:1b4b46a33f459a2951b0fd26c2d80639810631eb99b3d846d298b02d28a3e31d", + "sha256:1d616d80c37a388891bf760d64bc50cac7c61dbb7d7013f2373aa4b44936e9f0", + "sha256:225aefa7befbe05bd0116ef87e8cd76cbf4ac39457a66faf7fb5f3c2d7bea19a", + "sha256:2c9b28985ef7c830d5c7ea344d068bcdee22f8b6c251369dea98c3a814713d44", + "sha256:39e0600f8dd72acb011d09960da560ba3451b1eca8de5557c15705afc9d35f0e", + "sha256:3c642c40ea1ca074397698446893a45cd6059d5d071fc3ba3915c430c125320f", + "sha256:42357c90b488fac38852bcd7b31dcd36b1e2325413960304c28b8d98e6ff5fd4", + "sha256:6ac668f27dbdf8a69c31252f501e128a69a60b43a44e43d712fb58ce3e5dfcca", + "sha256:713683da2e3f1dd81a920c995df5dda51f1fff2b3995f5864c3ee782fcdcb96c", + "sha256:73b6e7853b6d3bc0eac795044e700467631dff37a5a33d3230122b03076ac2f9", + "sha256:77534c1b9f4a5d0962392cad3f668d1a04036b807618e3357eb2c50d8b05f7f7", + "sha256:77b579ef57e27457064bb6bb4c8e5ede866af071af60fe3576226136048c6dfa", + "sha256:82cf28f18c935d66c15a6f82fda766a4138d21e78532a1946b8ec603019ba0b8", + "sha256:937e8f12f9edc0d2e351c09fc3e7335a65eefb75406339d488ee46ef241f75d8", + "sha256:985dbf59e92f475573a04598f9a00f92b4fdb64fc41f1df2ea6f33b689319537", + "sha256:9c4fab7599ba8c0dbf829272c48c519625c2b7f5630b49925802f1af3a77f1f4", + "sha256:9e8772be8455b49a85ad6dbf6ce433da7856ba481d6db36f53507ae540823b15", + "sha256:a06d6d88ce3be4b54deabd078810e3c077a8b2e20f0ce541c979b5dd49337031", + "sha256:a1da0cdc3bc45315d313af976dab900888dbb477d812997ee0e6e4ea43d325e5", + "sha256:a6652466a4800e9fde04bf0252e914fff5f05e2a40ee1453db898149624dfe04", + "sha256:a7f23523ea6a01f77e0c6da8aae37ab7943e35630a8d2eda7e49502f36b51b46", + "sha256:a87429da49f4c9fb37a6a171fa38b59a99efdeabffb34b4255a7a849ffd74a20", + "sha256:c26bb81d0d19619367a96593a097baec2d5a7b3a0cfd1e3a9470277505a465c2", + "sha256:d4f4545edb4987f00fde44241cef436bf6471aaac7d21c6bbd497cca6049f613", + "sha256:daabc2766a2b76b3bec2086954c48d5f215f75a335eaee1e89c8357922a3c4d5", + "sha256:f08c1dcac70b558183b3b755b92f1135a76fd1caa04009b89ddea57a815599aa" + ], + "version": "==4.5.1" }, "packaging": { "hashes": [ - "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", - "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" + "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", + "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9" ], - "version": "==17.1" + "version": "==18.0" }, "pillow": { "hashes": [ - "sha256:00def5b638994f888d1058e4d17c86dec8e1113c3741a0a8a659039aec59a83a", - "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", - "sha256:8194d913ca1f459377c8a4ed8f9b7ad750068b8e0e3f3f9c6963fcc87a84515f", - "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" + "sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360", + "sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7", + "sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e", + "sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93", + "sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c", + "sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8", + "sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b", + "sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0", + "sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd", + "sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb", + "sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581", + "sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64", + "sha256:9577888ecc0ad7d06c3746afaba339c94d62b59da16f7a5d1cff9e491f23dace", + "sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956", + "sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60", + "sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16", + "sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37", + "sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae", + "sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a", + "sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073", + "sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8", + "sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb", + "sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409", + "sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f", + "sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2", + "sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4", + "sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74", + "sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e", + "sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1", + "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888" ], "index": "pypi", - "version": "==5.2.0" + "version": "==5.3.0" }, "pycares": { "hashes": [ @@ -325,6 +408,12 @@ ], "version": "==2.3.0" }, + "pycparser": { + "hashes": [ + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + ], + "version": "==2.19" + }, "pygments": { "hashes": [ "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", @@ -332,39 +421,70 @@ ], "version": "==2.2.0" }, + "pynacl": { + "hashes": [ + "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca", + "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512", + "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6", + "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776", + "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac", + "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b", + "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb", + "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98", + "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd", + "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2", + "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef", + "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f", + "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988", + "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b", + "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415", + "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2", + "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101", + "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0", + "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582", + "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a", + "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975", + "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1", + "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb", + "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45", + "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031", + "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9", + "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752", + "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0", + "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c", + "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053", + "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4" + ], + "version": "==1.2.1" + }, "pyparsing": { "hashes": [ - "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", - "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", - "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", - "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", - "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", - "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58", - "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010" + "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b", + "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592" ], - "version": "==2.2.0" + "version": "==2.3.0" }, "python-dateutil": { "hashes": [ - "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", - "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" + "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", + "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" ], "index": "pypi", - "version": "==2.7.3" + "version": "==2.7.5" }, "python-json-logger": { "hashes": [ - "sha256:a292e22c5e03105a05a746ade6209d43db1c4c763b91c75c8486e81d10904d85", - "sha256:e3636824d35ba6a15fc39f573588cba63cf46322a5dc86fb2f280229077e9fbe" + "sha256:3e000053837500f9eb28d6228d7cb99fabfc1874d34b40c08289207292abaf2e", + "sha256:cf2caaf34bd2eff394915b6242de4d0245de79971712439380ece6f149748cde" ], - "version": "==0.1.9" + "version": "==0.1.10" }, "pytz": { "hashes": [ - "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", - "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277" + "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", + "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" ], - "version": "==2018.5" + "version": "==2018.7" }, "pyyaml": { "hashes": [ @@ -385,10 +505,11 @@ }, "requests": { "hashes": [ - "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", - "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" + "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", + "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" ], - "version": "==2.19.1" + "index": "pypi", + "version": "==2.20.1" }, "shortuuid": { "hashes": [ @@ -412,11 +533,11 @@ }, "sphinx": { "hashes": [ - "sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc", - "sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896" + "sha256:120732cbddb1b2364471c3d9f8bfd4b0c5b550862f99a65736c77f970b142aea", + "sha256:b348790776490894e0424101af9c8413f2a86831524bd55c5f379d3e3e12ca64" ], "index": "pypi", - "version": "==1.7.6" + "version": "==1.8.2" }, "sphinxcontrib-websupport": { "hashes": [ @@ -425,76 +546,68 @@ ], "version": "==1.1.0" }, - "sympy": { - "hashes": [ - "sha256:286ca070d72e250861dea7a21ab44f541cb2341e8268c70264cf8642dbd9225f" - ], - "index": "pypi", - "version": "==1.2" - }, "urllib3": { "hashes": [ - "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", - "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" ], - "version": "==1.23" + "version": "==1.24.1" }, "websockets": { "hashes": [ - "sha256:0c31bc832d529dc7583d324eb6c836a4f362032a1902723c112cf57883488d8c", - "sha256:1f3e5a52cab6daa3d432c7b0de0a14109be39d2bfaad033ee5de4a3d3e11dcdf", - "sha256:341824d8c9ad53fc43cca3fa9407f294125fa258592f7676640396501448e57e", - "sha256:367ff945bc0950ad9634591e2afe50bf2222bc4fad1088a386c4bb700888026e", - "sha256:3859ca16c229ddb0fa21c5090e4efcb037c08ce69b0c1dfed6122c3f98cd0c22", - "sha256:3d425ae081fb4ba1eef9ecf30472ffd79f8e868297ccc7a47993c96dbf2a819c", - "sha256:64896a6b3368c959b8096b655e46f03dfa65b96745249f374bd6a35705cc3489", - "sha256:6df87698022aef2596bffdfecc96d656db59c8d719708c8a471daa815ee61656", - "sha256:80188abdadd23edaaea05ce761dc9a2e1df31a74a0533967f0dcd9560c85add0", - "sha256:d1a0572b6edb22c9208e3e5381064e09d287d2a915f90233fef994ee7a14a935", - "sha256:da4d4fbe059b0453e726d6d993760065d69b823a27efc3040402a6fcfe6a1ed9", - "sha256:da7610a017f5343fdf765f4e0eb6fd0dfd08264ca1565212b110836d9367fc9c", - "sha256:ebdd4f18fe7e3bea9bd3bf446b0f4117739478caa2c76e4f0fb72cc45b03cbd7", - "sha256:f5192da704535a7cbf76d6e99c1ec4af7e8d1288252bf5a2385d414509ded0cf", - "sha256:fd81af8cf3e69f9a97f3a6c0623a0527de0f922c2df725f00cd7646d478af632", - "sha256:fecf51c13195c416c22422353b306dddb9c752e4b80b21e0fa1fccbe38246677" - ], - "index": "pypi", - "version": "==4.0.1" + "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", + "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", + "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", + "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", + "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", + "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", + "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", + "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", + "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", + "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", + "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", + "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", + "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", + "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", + "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", + "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", + "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", + "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", + "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", + "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", + "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454" + ], + "version": "==6.0" }, "yarl": { "hashes": [ - "sha256:045dbba18c9142278113d5dc62622978a6f718ba662392d406141c59b540c514", - "sha256:17e57a495efea42bcfca08b49e16c6d89e003acd54c99c903ea1cb3de0ba1248", - "sha256:213e8f54b4a942532d6ac32314c69a147d3b82fa1725ca05061b7c1a19a1d9b1", - "sha256:3353fae45d93cc3e7e41bfcb1b633acc37db821d368e660b03068dbfcf68f8c8", - "sha256:51a084ff8756811101f8b5031a14d1c2dd26c666976e1b18579c6b1c8761a102", - "sha256:5580f22ac1298261cd24e8e584180d83e2cca9a6167113466d2d16cb2aa1f7b1", - "sha256:64727a2593fdba5d6ef69e94eba793a196deeda7152c7bd3a64edda6b1f95f6e", - "sha256:6e75753065c310befab71c5077a59b7cb638d2146b1cfbb1c3b8f08b51362714", - "sha256:7236eba4911a5556b497235828e7a4bc5d90957efa63b7c4b3e744d2d2cf1b94", - "sha256:a69dd7e262cdb265ac7d5e929d55f2f3d07baaadd158c8f19caebf8dde08dfe8", - "sha256:d9ca55a5a297408f08e5401c23ad22bd9f580dab899212f0d5dc1830f0909404", - "sha256:e072edbd1c5628c0b8f97d00cf6c9fcd6a4ee2b5ded10d463fcb6eaa066cf40c", - "sha256:e9a6a319c4bbfb57618f207e86a7c519ab0f637be3d2366e4cdac271577834b8" - ], - "index": "pypi", - "version": "==1.1.1" + "sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9", + "sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee", + "sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308", + "sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357", + "sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78", + "sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8", + "sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1", + "sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4", + "sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7" + ], + "version": "==1.2.6" } }, "develop": { "attrs": { "hashes": [ - "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", - "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" ], - "version": "==18.1.0" + "version": "==18.2.0" }, "certifi": { "hashes": [ - "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", - "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" + "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", + "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" ], - "version": "==2018.4.16" + "version": "==2018.10.15" }, "chardet": { "hashes": [ @@ -505,10 +618,10 @@ }, "click": { "hashes": [ - "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", - "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" ], - "version": "==6.7" + "version": "==7.0" }, "dodgy": { "hashes": [ @@ -526,19 +639,19 @@ }, "flake8": { "hashes": [ - "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0", - "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37" + "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670", + "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2" ], "index": "pypi", - "version": "==3.5.0" + "version": "==3.6.0" }, "flake8-bugbear": { "hashes": [ - "sha256:541746f0f3b2f1a8d7278e1d2d218df298996b60b02677708560db7c7e620e3b", - "sha256:5f14a99d458e29cb92be9079c970030e0dd398b2decb179d76d39a5266ea1578" + "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83", + "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a" ], "index": "pypi", - "version": "==18.2.0" + "version": "==18.8.0" }, "flake8-import-order": { "hashes": [ @@ -587,36 +700,31 @@ }, "packaging": { "hashes": [ - "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", - "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" + "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", + "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9" ], - "version": "==17.1" + "version": "==18.0" }, "pycodestyle": { "hashes": [ - "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766", - "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9" + "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", + "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" ], - "version": "==2.3.1" + "version": "==2.4.0" }, "pyflakes": { "hashes": [ - "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f", - "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" + "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49", + "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae" ], - "version": "==1.6.0" + "version": "==2.0.0" }, "pyparsing": { "hashes": [ - "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", - "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", - "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", - "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", - "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", - "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58", - "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010" + "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b", + "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592" ], - "version": "==2.2.0" + "version": "==2.3.0" }, "pyyaml": { "hashes": [ @@ -637,18 +745,19 @@ }, "requests": { "hashes": [ - "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", - "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" + "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", + "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" ], - "version": "==2.19.1" + "index": "pypi", + "version": "==2.20.1" }, "safety": { "hashes": [ - "sha256:2689fe629bafe9450796d36578aa112820ff65038578aee004f60b9db1ba4ae8", - "sha256:cd04e57ff8cf8984ff2cb11973e1d5469dae681e25d4edfccb1ef08cc107b2c0" + "sha256:399511524f47230d5867f1eb75548f9feefb7a2711a4985cb5be0e034f87040f", + "sha256:69b970918324865dcd7b92337e07152a0ea1ceecaf92f4d3b38529ee0ca83441" ], "index": "pypi", - "version": "==1.8.3" + "version": "==1.8.4" }, "six": { "hashes": [ @@ -659,10 +768,10 @@ }, "urllib3": { "hashes": [ - "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", - "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" ], - "version": "==1.23" + "version": "==1.24.1" } } } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index bc523fb31..6a63cfe21 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -4,7 +4,6 @@ variables: PIPENV_HIDE_EMOJIS: 1 PIPENV_IGNORE_VIRTUALENVS: 1 PIPENV_NOSPIN: 1 - PIPENV_VENV_IN_PROJECT: 1 jobs: - job: test @@ -12,34 +11,35 @@ jobs: pool: vmImage: 'Ubuntu 16.04' - + variables: PIPENV_CACHE_DIR: ".cache/pipenv" PIP_CACHE_DIR: ".cache/pip" + PIP_SRC: ".cache/src" steps: - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev displayName: 'Install base dependencies' - + - task: UsePythonVersion@0 displayName: 'Set Python version' inputs: - versionSpec: '3.7.x' - addToPath: true + versionSpec: '3.7.x' + addToPath: true - script: sudo pip install pipenv displayName: 'Install pipenv' - + - script: pipenv install --dev --deploy --system displayName: 'Install project using pipenv' - + - script: python -m flake8 displayName: 'Run linter' - job: build displayName: 'Build Containers' dependsOn: 'test' - + steps: - task: Docker@1 displayName: 'Login: Docker Hub' diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index ea36b5ebd..12edb202f 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -153,7 +153,7 @@ class Alias: @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) async def tags_get_alias( - self, ctx: Context, *, tag_name: TagNameConverter=None + self, ctx: Context, *, tag_name: TagNameConverter = None ): """ Alias for invoking tags get [tag_name]. diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 252695027..6353557d9 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -285,9 +285,9 @@ class Bot: howto = ( "It looks like you are trying to paste code into this channel.\n\n" "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \`\`\`, not `{ticks}`.\n\n" + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" "**Here is an example of how it should look:**\n" - f"\`\`\`python\n{content}\n\`\`\`\n\n**This will result in the following:**\n" + f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n" f"```python\n{content}\n```" ) @@ -329,7 +329,7 @@ class Bot: "syntax highlighting. Please use these whenever you paste code, as this " "helps improve the legibility and makes it easier for us to help you.\n\n" f"**To do this, use the following method:**\n" - f"\`\`\`python\n{content}\n\`\`\`\n\n**This will result in the following:**\n" + f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n" f"```python\n{content}\n```" ) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 8261b0a3b..651aa048b 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -97,8 +97,7 @@ class CodeEval: res = (res, out) else: - if (isinstance(out, str) and - out.startswith("Traceback (most recent call last):\n")): + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): # Leave out the traceback message out = "\n" + "\n".join(out.split("\n")[1:]) @@ -115,9 +114,9 @@ class CodeEval: # Text too long, shorten li = pretty.split("\n") - pretty = ("\n".join(li[:3]) + # First 3 lines - "\n ...\n" + # Ellipsis to indicate removed lines - "\n".join(li[-3:])) # last 3 lines + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines # Add the output res += pretty diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 0b9b75a00..3537c850a 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -188,10 +188,10 @@ class Events: async def on_member_update(self, before: Member, after: Member): if ( - before.roles == after.roles and - before.name == after.name and - before.discriminator == after.discriminator and - before.avatar == after.avatar): + before.roles == after.roles + and before.name == after.name + and before.discriminator == after.discriminator + and before.avatar == after.avatar): return before_role_names = [role.name for role in before.roles] # type: List[str] diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 36be78a7e..a8b5091af 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -22,7 +22,7 @@ INVITE_RE = ( r"([a-zA-Z0-9]+)" # the invite code itself ) -URL_RE = "(https?://[^\s]+)" +URL_RE = r"(https?://[^\s]+)" ZALGO_RE = r"[\u0300-\u036F\u0489]" RETARDED_RE = r"(re+)tar+(d+|t+)(ed)?" SELF_DEPRECATION_RE = fr"((i'?m)|(i am)|(it'?s)|(it is)) (.+? )?{RETARDED_RE}" diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index cdc2861b1..a0ba7fdd1 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -102,13 +102,13 @@ class Tags: return tag_data @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter=None): + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): """Show all known tags, a single tag, or run a subcommand.""" await ctx.invoke(self.get_command, tag_name=tag_name) @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter=None): + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None): """ Get a list of all tags or a specified tag. diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index aabd83f9f..c36ef6075 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -31,9 +31,9 @@ guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60* async def send_embed( ctx: Context, message_txt: str, - colour: int=Colours.soft_red, - footer: str=None, - img_url: str=None, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, f: discord.File = None ) -> None: """ diff --git a/bot/pagination.py b/bot/pagination.py index cfd6287f7..0d8e8aaa3 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -95,7 +95,7 @@ class LinePaginator(Paginator): @classmethod async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, - empty: bool = True, restrict_to_user: User = None, timeout: int=300, + empty: bool = True, restrict_to_user: User = None, timeout: int = 300, footer_text: str = None): """ Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to @@ -129,9 +129,9 @@ class LinePaginator(Paginator): no_restrictions = ( # Pagination is not restricted - not restrict_to_user or + not restrict_to_user # The reaction was by a whitelisted user - user_.id == restrict_to_user.id + or user_.id == restrict_to_user.id ) return ( @@ -291,7 +291,7 @@ class ImagePaginator(Paginator): self.images = [] self._pages = [] - def add_line(self, line: str='', *, empty: bool=False) -> None: + def add_line(self, line: str = '', *, empty: bool = False) -> None: """ Adds a line to each page, usually just 1 line in this context :param line: str to be page content / title @@ -305,7 +305,7 @@ class ImagePaginator(Paginator): self._current_page.append(line) self.close_page() - def add_image(self, image: str=None) -> None: + def add_image(self, image: str = None) -> None: """ Adds an image to a page :param image: image url to be appended @@ -315,7 +315,7 @@ class ImagePaginator(Paginator): @classmethod async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, - prefix: str="", suffix: str="", timeout: int=300): + prefix: str = "", suffix: str = "", timeout: int = 300): """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs.The reactions are diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 1a902b68c..87351eaf3 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -35,9 +35,9 @@ async def disambiguate( choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) def check(message): - return (message.content.isdigit() and - message.author == ctx.author and - message.channel == ctx.channel) + return (message.content.isdigit() + and message.author == ctx.author + and message.channel == ctx.channel) try: if embed is None: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 63e41983b..e697b0ed6 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -13,8 +13,8 @@ MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], - deletion_emojis: Sequence[str]=("❌",), - timeout: float=60 * 5, + deletion_emojis: Sequence[str] = ("❌",), + timeout: float = 60 * 5, attach_emojis=True, client=None ): @@ -62,9 +62,9 @@ async def wait_for_deletion( def check(reaction, user): return ( - reaction.message.id == message.id and - reaction.emoji in deletion_emojis and - user.id in user_ids + reaction.message.id == message.id + and reaction.emoji in deletion_emojis + and user.id in user_ids ) with contextlib.suppress(asyncio.TimeoutError): diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py index c37ac0f50..b9d29583f 100644 --- a/bot/utils/snakes/hatching.py +++ b/bot/utils/snakes/hatching.py @@ -1,36 +1,36 @@ -h1 = '''``` +h1 = r'''``` ---- ------ - /--------\\ + /--------\ |--------| |--------| \------/ ----```''' -h2 = '''``` +h2 = r'''``` ---- ------ - /---\\-/--\\ - |-----\\--| + /---\-/--\ + |-----\--| |--------| \------/ ----```''' -h3 = '''``` +h3 = r'''``` ---- ------ - /---\\-/--\\ - |-----\\--| + /---\-/--\ + |-----\--| |-----/--| - \----\\-/ + \----\-/ ----```''' -h4 = '''``` +h4 = r'''``` ----- - ----- \\ - /--| /---\\ - |--\\ -\\---| - |--\\--/-- / + ----- \ + /--| /---\ + |--\ -\---| + |--\--/-- / \------- / ------```''' diff --git a/tox.ini b/tox.ini index fb2176741..c6fa513f4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [flake8] max-line-length=120 application_import_names=bot -exclude=.venv +exclude=.cache,.venv ignore=B311,W503,E226,S311 import-order-style=pycharm -- cgit v1.2.3 From 8824cbfffc436bc5b089253d23b612b75a6879ca Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Mon, 26 Nov 2018 13:07:59 +0100 Subject: Delete GitLab CI YAML - we're on Azure now, and this still triggers --- .gitlab-ci.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 44126ded9..000000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -image: pythondiscord/bot-ci:latest - -variables: - PIPENV_CACHE_DIR: "/root/.cache/pipenv" - PIP_CACHE_DIR: "/root/.cache/pip" - -cache: - paths: - - "/root/.cache/pip/" - - "/root/.cache/pipenv/" - - "/usr/local/lib/python3.6/site-packages/" - -stages: - - test - - build - -test: - tags: - - docker - - stage: test - - script: - - ls /root/.cache/ - - pipenv install --dev --deploy --system - - python -m flake8 - - ls /root/.cache/ - -- cgit v1.2.3 From 8cd2f733baa00ddeccf8f3e84594b658fff24694 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 26 Nov 2018 23:46:23 +0100 Subject: Refactored - codeblocks now make use of the bot.utils.messages util. (#202) * Refactored - codeblocks now make use of the bot.utils.messages util. * Update bot/cogs/bot.py Co-Authored-By: heavysaturn * Fix up flake8 complaints. Co-Authored-By: jchristgit --- bot/cogs/bot.py | 46 +++++++--------------------------------------- bot/utils/messages.py | 4 +++- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 6353557d9..b684ad886 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -3,14 +3,15 @@ import logging import re import time -from discord import Embed, Message, RawMessageUpdateEvent, RawReactionActionEvent +from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Context, command, group from dulwich.repo import Repo from bot.constants import ( - Channels, Emojis, Guild, Roles, URLs + Channels, Guild, Roles, URLs ) from bot.decorators import with_role +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -342,7 +343,10 @@ class Bot: howto_embed = Embed(description=howto) bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) self.codeblock_message_ids[msg.id] = bot_message.id - await bot_message.add_reaction(Emojis.cross_mark) + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) else: return @@ -380,42 +384,6 @@ class Bot: await bot_message.delete() del self.codeblock_message_ids[payload.message_id] - async def on_raw_reaction_add(self, payload: RawReactionActionEvent): - # Ignores reactions added by the bot or added to non-codeblock correction embed messages - # Also ignores the reaction if the user can't be loaded - # Retrieve Member object instead of user in order to compare roles later - # Try except used to catch instances where guild_id not in payload. - try: - member = self.bot.get_guild(payload.guild_id).get_member(payload.user_id) - except AttributeError: - return - - if member is None: - return - if member.bot or payload.message_id not in self.codeblock_message_ids.values(): - return - - # Finds the appropriate bot message/ user message pair and assigns them to variables - for user_message_id, bot_message_id in self.codeblock_message_ids.items(): - if bot_message_id == payload.message_id: - channel = self.bot.get_channel(payload.channel_id) - user_message = await channel.get_message(user_message_id) - bot_message = await channel.get_message(bot_message_id) - break - - # If the reaction was clicked on by the author of the user message, deletes the bot message - if member.id == user_message.author.id: - await bot_message.delete() - del self.codeblock_message_ids[user_message_id] - return - - # If the reaction was clicked by staff (helper or higher), deletes the bot message - for role in member.roles: - if role.id in (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers): - await bot_message.delete() - del self.codeblock_message_ids[user_message_id] - return - def setup(bot): bot.add_cog(Bot(bot)) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index e697b0ed6..fc38b0127 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -7,13 +7,15 @@ from discord import Embed, File, Message, TextChannel from discord.abc import Snowflake from discord.errors import HTTPException +from bot.constants import Emojis + MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], - deletion_emojis: Sequence[str] = ("❌",), + deletion_emojis: Sequence[str] = (Emojis.cross_mark,), timeout: float = 60 * 5, attach_emojis=True, client=None -- cgit v1.2.3 From aa5c64d79c489188cb05b6c805fdc489dedaef44 Mon Sep 17 00:00:00 2001 From: Joseph Date: Thu, 29 Nov 2018 19:37:29 +0000 Subject: Add commas to server command (#209) --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 0f1aa75c5..7a244cdbe 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -103,7 +103,7 @@ class Information: Features: {features} **Counts** - Members: {member_count} + Members: {member_count:,} Roles: {roles} Text: {text_channels} Voice: {voice_channels} -- cgit v1.2.3 From c968f22f7dda90dc9c4256effc30de99fc7f58a4 Mon Sep 17 00:00:00 2001 From: Modelmat Date: Mon, 3 Dec 2018 16:49:33 +1100 Subject: Makes generating `...:` indents faster Uses `rjust` instead of an fstring: --- bot/cogs/eval.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 651aa048b..9e09b3aa0 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -66,9 +66,9 @@ class CodeEval: # far enough to align them. # we first `str()` the line number # then we get the length - # and do a simple {: Date: Wed, 5 Dec 2018 00:57:42 +0100 Subject: Fixes a bug with the watch alias introduced by our recent change of the watch command. --- bot/cogs/alias.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 12edb202f..7824b2c6b 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,7 +1,7 @@ import inspect import logging -from discord import Colour, Embed, TextChannel, User +from discord import Colour, Embed, User from discord.ext.commands import ( Command, Context, clean_content, command, group ) @@ -71,13 +71,13 @@ class Alias: @command(name="watch", hidden=True) async def bigbrother_watch_alias( - self, ctx, user: User, channel: TextChannel = None + self, ctx, user: User, reason: str = None ): """ Alias for invoking bigbrother watch user [text_channel]. """ - await self.invoke(ctx, "bigbrother watch", user, channel) + await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) async def bigbrother_unwatch_alias(self, ctx, user: User): -- cgit v1.2.3 From b85699ab48be91a3a170c8bad373f1bc91a85096 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Wed, 5 Dec 2018 13:13:10 +0100 Subject: Update bot/cogs/alias.py Co-Authored-By: heavysaturn --- bot/cogs/alias.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 7824b2c6b..2ce4a51e3 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 = None ): """ Alias for invoking bigbrother watch user [text_channel]. -- cgit v1.2.3 From 8e5886566d970f9fa6aec8a357bbf5c212b792fe Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 5 Dec 2018 21:29:57 +0100 Subject: Adding git to the base dockerfile --- docker/base.Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index a1ec0866e..e46db756a 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -8,6 +8,7 @@ RUN apk add --update jpeg-dev RUN apk add --update libxml2 libxml2-dev libxslt-dev RUN apk add --update zlib-dev RUN apk add --update freetype-dev +RUN apk add --update git ENV LIBRARY_PATH=/lib:/usr/lib ENV PIPENV_VENV_IN_PROJECT=1 -- cgit v1.2.3 From 81b0bfaac0a1ac3d8c810d15aee51939f622fd4c Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Tue, 18 Dec 2018 15:43:12 +0100 Subject: Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bfa9d3e42..1c9e52b71 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,5 @@ [![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot%20(Mainline))](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1) [![Discord](https://discordapp.com/api/guilds/267624335836053506/embed.png)](https://discord.gg/2B963hn) -This project is a Discord bot specifically for use with the Python Discord server. It will provide numerous utilities +This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities and other tools to help keep the server running like a well-oiled machine. -- cgit v1.2.3 From 820cb0c1e838faefd898bd10f5dc767a86ec84f7 Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 18 Dec 2018 16:55:53 -0500 Subject: Make reason a required input to bb watch Resolves #218 --- bot/cogs/bigbrother.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 7964c81a8..0f2cc7ac8 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -220,9 +220,14 @@ class BigBrother: """ Relay messages sent by the given `user` to the `#big-brother-logs` channel - If a `reason` is specified, a note is added for `user` + A `reason` for watching is required, which is added for the user to be watched as a + note (aka: shadow warning) """ + if not reason: + await ctx.send(":x: A reason for watching this user is required") + return + channel_id = Channels.big_brother_logs post_data = { @@ -251,10 +256,9 @@ class BigBrother: reason = data.get('error_message', "no message provided") await ctx.send(f":x: the API returned an error: {reason}") - # Add a note (shadow warning) if a reason is specified - if reason: - reason = "bb watch: " + reason # Prepend for situational awareness - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + # 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) @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) -- cgit v1.2.3 From 97bedd10cd6c3e1760e115022915566337e42c60 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 20 Dec 2018 16:34:32 -0500 Subject: Fix initial emoji config Resolves #220 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 41383a6ae..c173330d2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -25,7 +25,7 @@ style: green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" white_chevron: "<:whitechevron:418110396973711363>" - lemoneye2: "<:lemoneye2:435193765582340098>" + bb_message: "<:lemoneye2:435193765582340098>" status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" -- cgit v1.2.3 From d19bd1315d84dbd28af8e96e70e6d079a668b443 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 20 Dec 2018 16:53:42 -0500 Subject: Update config-default.yml Co-Authored-By: sco1 --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index c173330d2..25c8b1e6d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -25,7 +25,7 @@ style: green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:lemoneye2:435193765582340098>" + bb_message: "<:bbmessage:472476937504423936>" status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" -- cgit v1.2.3 From d619603cf947ebaea73db387c39e20c9074ae9d1 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 20 Dec 2018 17:08:19 -0500 Subject: Realigned emoji groupings --- config-default.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config-default.yml b/config-default.yml index 25c8b1e6d..e7145289d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -19,13 +19,13 @@ style: emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" - defcon_enabled: "<:defconenabled:470326274213150730>" - defcon_updated: "<:defconsettingsupdated:470326274082996224>" + defcon_enabled: "<:defconenabled:470326274213150730>" + defcon_updated: "<:defconsettingsupdated:470326274082996224>" green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:bbmessage:472476937504423936>" + bb_message: "<:bbmessage:472476937504423936>" status_online: "<:status_online:470326272351010816>" status_idle: "<:status_idle:470326266625785866>" @@ -42,7 +42,7 @@ style: crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" - defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" + defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" @@ -68,8 +68,8 @@ 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/472472639206719508.png" + user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" -- cgit v1.2.3 From 80e8dc84bcd0a4503c07fc686e5ff4017afdb1eb Mon Sep 17 00:00:00 2001 From: Tagptroll1 Date: Mon, 24 Dec 2018 16:54:26 +0100 Subject: CommandNotFound exceptions will attempt tofind a valid tag before silently ignoring --- bot/cogs/events.py | 12 +++++++++--- bot/cogs/tags.py | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 3537c850a..edfc6e579 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -3,8 +3,8 @@ import logging from discord import Colour, Embed, Member, Object from discord.ext.commands import ( BadArgument, Bot, BotMissingPermissions, - CommandError, CommandInvokeError, Context, - NoPrivateMessage, UserInputError + CommandError, CommandInvokeError, CommandNotFound, + Context, NoPrivateMessage, UserInputError ) from bot.cogs.modlog import ModLog @@ -121,7 +121,13 @@ class Events: log.debug(f"Command {command} has a local error handler, ignoring.") return - if isinstance(e, BadArgument): + if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + # Return to not raise the exception + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) elif isinstance(e, UserInputError): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a0ba7fdd1..b128b6de1 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -180,6 +180,9 @@ class Tags: if tag_data['image_url'] is not None: embed.set_image(url=tag_data['image_url']) + # If its invoked from error handler just ignore it. + elif hasattr(ctx, "invoked_from_error_handler"): + return # If not, prepare an error message. else: embed.colour = Colour.red() -- cgit v1.2.3 From 4716565247ea749193f19f0e95e05eeaf4d70288 Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 24 Dec 2018 16:39:40 -0500 Subject: Remove reason check --- bot/cogs/bigbrother.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 0f2cc7ac8..31dbf8b5e 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -216,18 +216,13 @@ class BigBrother: @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str = None): + async def watch_command(self, ctx: Context, user: User, *, reason: str): """ Relay messages sent by the given `user` to the `#big-brother-logs` channel A `reason` for watching is required, which is added for the user to be watched as a note (aka: shadow warning) """ - - if not reason: - await ctx.send(":x: A reason for watching this user is required") - return - channel_id = Channels.big_brother_logs post_data = { -- cgit v1.2.3 From 6b49afeb6afe6ecdbc5325723c3106588b85c62b Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 24 Dec 2018 16:40:24 -0500 Subject: Add linebreak --- bot/cogs/bigbrother.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 31dbf8b5e..29b13f038 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -223,6 +223,7 @@ class BigBrother: A `reason` for watching is required, which is added for the user to be watched as a note (aka: shadow warning) """ + channel_id = Channels.big_brother_logs post_data = { -- cgit v1.2.3 From bf531350d50c200f9dfa06525c97d6ad1a9f75d1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 27 Dec 2018 00:59:26 +1000 Subject: Add DM emoji indicator for infr notify success. --- bot/cogs/moderation.py | 89 +++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 6e958b912..0fc47c9df 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -82,7 +82,7 @@ class Moderation(Scheduler): :param reason: The reason for the warning. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Warning", reason=reason @@ -92,12 +92,13 @@ class Moderation(Scheduler): if response_object is None: return + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: warned {user.mention}" + if reason is None: - result_message = f":ok_hand: warned {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: warned {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") @with_role(*MODERATION_ROLES) @command(name="kick") @@ -108,7 +109,7 @@ class Moderation(Scheduler): :param reason: The reason for the kick. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Kick", reason=reason @@ -121,12 +122,13 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_remove, user.id) await user.kick(reason=reason) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: kicked {user.mention}" + if reason is None: - result_message = f":ok_hand: kicked {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: kicked {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -150,7 +152,7 @@ class Moderation(Scheduler): :param reason: The reason for the ban. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Ban", duration="Permanent", @@ -165,12 +167,13 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_remove, user.id) await ctx.guild.ban(user, reason=reason, delete_message_days=0) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: permanently banned {user.mention}" + if reason is None: - result_message = f":ok_hand: permanently banned {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: permanently banned {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -194,7 +197,7 @@ class Moderation(Scheduler): :param reason: The reason for the mute. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Mute", duration="Permanent", @@ -209,12 +212,13 @@ class Moderation(Scheduler): self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: permanently muted {user.mention}" + if reason is None: - result_message = f":ok_hand: permanently muted {user.mention}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: permanently muted {user.mention} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -242,7 +246,7 @@ class Moderation(Scheduler): :param reason: The reason for the temporary mute. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Mute", duration=duration, @@ -262,12 +266,13 @@ class Moderation(Scheduler): loop = asyncio.get_event_loop() self.schedule_task(loop, infraction_object["id"], infraction_object) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" + if reason is None: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -294,7 +299,7 @@ class Moderation(Scheduler): :param reason: The reason for the temporary ban. """ - await self.notify_infraction( + notified = await self.notify_infraction( user=user, infr_type="Ban", duration=duration, @@ -316,12 +321,13 @@ class Moderation(Scheduler): loop = asyncio.get_event_loop() self.schedule_task(loop, infraction_object["id"], infraction_object) + dm_result = ":incoming_envelope: " if notified else "" + action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" + if reason is None: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}." + await ctx.send(f"{action}.") else: - result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})." - - await ctx.send(result_message) + await ctx.send(f"{action} ({reason}).") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -603,7 +609,15 @@ class Moderation(Scheduler): if infraction_object["expires_at"] is not None: self.cancel_expiration(infraction_object["id"]) - await ctx.send(f":ok_hand: Un-muted {user.mention}.") + notified = await self.notify_pardon( + user=user, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + + dm_result = ":incoming_envelope: " if notified else "" + await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.") # Send a log message to the mod log await self.mod_log.send_log_message( @@ -617,13 +631,6 @@ class Moderation(Scheduler): Intended expiry: {infraction_object['expires_at']} """) ) - - await self.notify_pardon( - user=user, - title="You have been unmuted.", - content="You may now send messages in the server.", - icon_url=Icons.user_unmute - ) except Exception: log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") @@ -1093,7 +1100,7 @@ class Moderation(Scheduler): embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL - await self.send_private_embed(user, embed) + return await self.send_private_embed(user, embed) async def notify_pardon( self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified @@ -1114,7 +1121,7 @@ class Moderation(Scheduler): embed.set_author(name=title, icon_url=icon_url) - await self.send_private_embed(user, embed) + return await self.send_private_embed(user, embed) async def send_private_embed(self, user: Union[User, Member], embed: Embed): """ @@ -1129,11 +1136,13 @@ class Moderation(Scheduler): try: await user.send(embed=embed) + return True except (HTTPException, Forbidden): log.debug( f"Infraction-related information could not be sent to user {user} ({user.id}). " "They've probably just disabled private messages." ) + return False # endregion -- cgit v1.2.3 From c8be1f1725f497399c7c75f404f85591d7b189ed Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 27 Dec 2018 04:05:37 +1000 Subject: Add charinfo command. --- bot/cogs/utils.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b101b8816..d605cd201 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,8 +1,9 @@ import logging +import re +import unicodedata from email.parser import HeaderParser from io import StringIO - from discord import Colour, Embed from discord.ext.commands import AutoShardedBot, Context, command @@ -87,6 +88,50 @@ class Utils: await ctx.message.channel.send(embed=pep_embed) + @command() + async def charinfo(self, ctx, *, characters: str): + """ + Shows you information on up to 25 unicode characters. + """ + + match = re.match(r"<(a?):([a-zA-Z0-9\_]+):([0-9]+)>", characters) + if match: + embed = Embed( + title="Non-Character Detected", + description=( + "Only unicode characters can be processed, but a custom Discord emoji " + "was found. Please remove it and try again." + ) + ) + embed.colour = Colour.red() + return await ctx.send(embed=embed) + + if len(characters) > 25: + embed = Embed(title=f"Too many characters ({len(characters)}/25)") + embed.colour = Colour.red() + return await ctx.send(embed=embed) + + def get_info(char): + digit = f"{ord(char):x}" + if len(digit) <= 4: + u_code = f"\\u{digit:>04}" + else: + u_code = f"\\U{digit:>08}" + url = f"https://www.compart.com/en/unicode/U+{digit:>04}" + name = f"[{unicodedata.name(char, '')}]({url})" + info = f"`{u_code.ljust(10)}`: {name} - {char}" + return info, u_code + + charlist, rawlist = zip(*(get_info(c) for c in characters)) + + embed = Embed(description="\n".join(charlist)) + embed.set_author(name="Character Info") + + if len(characters) > 1: + embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False) + + await ctx.send(embed=embed) + def setup(bot): bot.add_cog(Utils(bot)) -- cgit v1.2.3 From 6dd7d7d641403d468418eb8b8d1d60d35e5d8e0d Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 27 Dec 2018 04:53:10 +1000 Subject: Add content kwarg to modlog messages. --- bot/cogs/modlog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 1d1546d5b..905f114c1 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -104,8 +104,9 @@ 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 + 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 ): embed = Embed(description=text) @@ -118,10 +119,11 @@ class ModLog: if thumbnail is not None: embed.set_thumbnail(url=thumbnail) - content = None - if ping_everyone: - content = "@everyone" + if content: + content = f"@everyone\n{content}" + else: + content = "@everyone" await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files) -- cgit v1.2.3 From 7a6e289636c131bf724f82c8f00a008b275eb79c Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 27 Dec 2018 04:53:47 +1000 Subject: Ping mod on infr notify failure in modlog. --- bot/cogs/moderation.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 0fc47c9df..24b60332f 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -100,6 +100,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "warning") + @with_role(*MODERATION_ROLES) @command(name="kick") async def kick(self, ctx: Context, user: Member, *, reason: str = None): @@ -130,6 +133,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "kick") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.sign_out, @@ -175,6 +181,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "ban") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, @@ -220,6 +229,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "mute") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, @@ -274,6 +286,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "mute") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_mute, @@ -329,6 +344,9 @@ class Moderation(Scheduler): else: await ctx.send(f"{action} ({reason}).") + if not notified: + await self.log_notify_failure(user, ctx.author, "ban") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_ban, @@ -619,6 +637,9 @@ class Moderation(Scheduler): dm_result = ":incoming_envelope: " if notified else "" await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.") + if not notified: + await self.log_notify_failure(user, ctx.author, "unmute") + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_unmute, @@ -1144,6 +1165,15 @@ class Moderation(Scheduler): ) return False + async def log_notify_failure(self, target: str, actor: Member, infraction_type: str): + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + content=actor.mention, + colour=Colour(Colours.soft_red), + title="Notification Failed", + text=f"Direct message was unable to be sent.\nUser: {target.mention}\nType: {infraction_type}" + ) + # endregion async def __error(self, ctx, error): -- cgit v1.2.3 From 618a5fc2c399129dbf4fb487cb44ece5f8eb6c18 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 27 Dec 2018 19:23:26 +0000 Subject: Disallow Group DM invites from the invite filter --- bot/cogs/filtering.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index a8b5091af..b6ce501fc 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -238,7 +238,15 @@ class Filtering: f"{URLs.discord_invite_api}/{invite}" ) response = await response.json() - guild_id = int(response.get("guild", {}).get("id")) + if response.get("guild") is None: + # If we have a valid invite which is not a guild invite + # it might be a DM channel invite + if response.get("channel") is not None: + # We don't have whitelisted Group DMs so we can + # go ahead and return a positive for any group DM + return True + + guild_id = int(response.get("guild").get("id")) if guild_id not in Filter.guild_invite_whitelist: return True -- cgit v1.2.3 From 2587568fa04c8e7511982e1e6b78df850c37b38f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Dec 2018 11:49:41 -0800 Subject: invite filter: remove redundant channel None check --- bot/cogs/filtering.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index b6ce501fc..0ba1e49c5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -238,15 +238,13 @@ class Filtering: f"{URLs.discord_invite_api}/{invite}" ) response = await response.json() - if response.get("guild") is None: - # If we have a valid invite which is not a guild invite - # it might be a DM channel invite - if response.get("channel") is not None: - # We don't have whitelisted Group DMs so we can - # go ahead and return a positive for any group DM - return True + guild = response.get("guild") + if guild is None: + # We don't have whitelisted Group DMs so we can + # go ahead and return a positive for any group DM + return True - guild_id = int(response.get("guild").get("id")) + guild_id = int(guild.get("id")) if guild_id not in Filter.guild_invite_whitelist: return True -- cgit v1.2.3 From a6b997fcebe747aa9bbfb92ce17d0725159a08fc Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 28 Dec 2018 08:21:38 +1000 Subject: Simplify regex pattern Co-Authored-By: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d605cd201..94f3938e4 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -94,7 +94,7 @@ class Utils: Shows you information on up to 25 unicode characters. """ - match = re.match(r"<(a?):([a-zA-Z0-9\_]+):([0-9]+)>", characters) + match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: embed = Embed( title="Non-Character Detected", -- cgit v1.2.3 From 81415a1261d2584e017b61609218583621be0e9c Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Fri, 28 Dec 2018 23:40:42 +1000 Subject: Expand in_channel check to accept multi-channels, bypass roles. --- bot/cogs/snekbox.py | 26 ++++++-------------------- bot/cogs/utils.py | 16 +++++++++++++--- bot/decorators.py | 44 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1b51da843..cb0454249 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -6,12 +6,12 @@ import textwrap from discord import Colour, Embed from discord.ext.commands import ( - Bot, CommandError, Context, MissingPermissions, - NoPrivateMessage, check, command, guild_only + Bot, CommandError, Context, NoPrivateMessage, command, guild_only ) from bot.cogs.rmq import RMQ from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs +from bot.decorators import InChannelCheckFailure, in_channel from bot.utils.messages import wait_for_deletion @@ -51,22 +51,8 @@ RAW_CODE_REGEX = re.compile( r"\s*$", # any trailing whitespace until the end of the string re.DOTALL # "." also matches newlines ) -BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) -WHITELISTED_CHANNELS = (Channels.bot,) -WHITELISTED_CHANNELS_STRING = ', '.join(f"<#{channel_id}>" for channel_id in WHITELISTED_CHANNELS) - - -async def channel_is_whitelisted_or_author_can_bypass(ctx: Context): - """ - Checks that the author is either helper or above - or the channel is a whitelisted channel. - """ - if ctx.channel.id in WHITELISTED_CHANNELS: - return True - if any(r.id in BYPASS_ROLES for r in ctx.author.roles): - return True - raise MissingPermissions("You are not allowed to do that here.") +BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) class Snekbox: @@ -84,7 +70,7 @@ class Snekbox: @command(name='eval', aliases=('e',)) @guild_only() - @check(channel_is_whitelisted_or_author_can_bypass) + @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) async def eval_command(self, ctx: Context, *, code: str = None): """ Run some code. get the result back. We've done our best to make this safe, but do let us know if you @@ -205,9 +191,9 @@ class Snekbox: embed.description = "You're not allowed to use this command in private messages." await ctx.send(embed=embed) - elif isinstance(error, MissingPermissions): + elif isinstance(error, InChannelCheckFailure): embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = f"Sorry, but you may only use this command within {WHITELISTED_CHANNELS_STRING}." + embed.description = str(error) await ctx.send(embed=embed) else: diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 94f3938e4..65c729414 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,4 +1,5 @@ import logging +import random import re import unicodedata from email.parser import HeaderParser @@ -7,11 +8,13 @@ from io import StringIO from discord import Colour, Embed from discord.ext.commands import AutoShardedBot, Context, command -from bot.constants import Roles -from bot.decorators import with_role +from bot.constants import Channels, NEGATIVE_REPLIES, Roles +from bot.decorators import InChannelCheckFailure, in_channel log = logging.getLogger(__name__) +BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) + class Utils: """ @@ -25,7 +28,6 @@ class Utils: self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - @with_role(Roles.verified) async def pep_command(self, ctx: Context, pep_number: str): """ Fetches information about a PEP and sends it to the channel. @@ -89,6 +91,7 @@ class Utils: await ctx.message.channel.send(embed=pep_embed) @command() + @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) async def charinfo(self, ctx, *, characters: str): """ Shows you information on up to 25 unicode characters. @@ -132,6 +135,13 @@ class Utils: await ctx.send(embed=embed) + async def __error(self, ctx, error): + embed = Embed(colour=Colour.red()) + if isinstance(error, InChannelCheckFailure): + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = str(error) + await ctx.send(embed=embed) + def setup(bot): bot.add_cog(Utils(bot)) diff --git a/bot/decorators.py b/bot/decorators.py index fe974cbd3..87877ecbf 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,18 +1,51 @@ import logging import random +import typing from asyncio import Lock from functools import wraps from weakref import WeakValueDictionary from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Context +from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES log = logging.getLogger(__name__) +class InChannelCheckFailure(CheckFailure): + pass + + +def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): + """ + Checks that the message is in a whitelisted channel or optionally has a bypass role. + """ + def predicate(ctx: Context): + if ctx.channel.id in channels: + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The command was used in a whitelisted channel.") + return True + + if bypass_roles: + if any(r.id in bypass_roles for r in ctx.author.roles): + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The command was not used in a whitelisted channel, " + f"but the author had a role to bypass the in_channel check.") + return True + + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The in_channel check failed.") + + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + return commands.check(predicate) + + def with_role(*role_ids: int): async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM @@ -46,15 +79,6 @@ def without_role(*role_ids: int): return commands.check(predicate) -def in_channel(channel_id): - async def predicate(ctx: Context): - check = ctx.channel.id == channel_id - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the in_channel check was {check}.") - return check - return commands.check(predicate) - - def locked(): """ Allows the user to only run one instance of the decorated command at a time. -- cgit v1.2.3 From c419a2516d63aba2ada59694e345f27eaa5a0e1a Mon Sep 17 00:00:00 2001 From: sco1 Date: Sat, 29 Dec 2018 14:27:44 -0500 Subject: Add mod log event for member warn & shadowwarn --- bot/cogs/moderation.py | 26 ++++++++++++++++++++++++++ bot/constants.py | 2 ++ config-default.yml | 2 ++ 3 files changed, 30 insertions(+) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 6e958b912..8add6fdde 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -99,6 +99,19 @@ class Moderation(Scheduler): await ctx.send(result_message) + # Send a message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_warn, + colour=Colour(Colours.soft_red), + title="Member warned", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + @with_role(*MODERATION_ROLES) @command(name="kick") async def kick(self, ctx: Context, user: Member, *, reason: str = None): @@ -361,6 +374,19 @@ class Moderation(Scheduler): await ctx.send(result_message) + # Send a message to the mod log + await self.mod_log.send_log_message( + icon_url=Icons.user_warn, + colour=Colour(Colours.soft_red), + title="Member shadow warned", + thumbnail=user.avatar_url_as(static_format="png"), + text=textwrap.dedent(f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + Reason: {reason} + """) + ) + @with_role(*MODERATION_ROLES) @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): diff --git a/bot/constants.py b/bot/constants.py index 5e7927ed9..99ef98da2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -292,6 +292,8 @@ class Icons(metaclass=YAMLGetter): user_unmute: str user_verified: str + user_warn: str + pencil: str remind_blurple: str diff --git a/config-default.yml b/config-default.yml index e7145289d..3a1ad8052 100644 --- a/config-default.yml +++ b/config-default.yml @@ -72,6 +72,8 @@ style: user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" + user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" + pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" -- cgit v1.2.3 From 2634865fa0e4f3a3bd2494c8306820c71aee6487 Mon Sep 17 00:00:00 2001 From: sco1 Date: Sat, 29 Dec 2018 21:06:35 -0500 Subject: Remove invite filter deblanking Clarify inline help/comments --- bot/cogs/filtering.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 0ba1e49c5..c21b45648 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -217,16 +217,12 @@ class Filtering: async def _has_invites(self, text: str) -> bool: """ - Returns True if the text contains an invite which - is not on the guild_invite_whitelist in config.yml. + Returns True if the text contains an invite which is not on the guild_invite_whitelist in + config.yml - Also catches a lot of common ways to try to cheat the system. + Attempts to catch some of common ways to try to cheat the system. """ - # Remove spaces to prevent cases like - # d i s c o r d . c o m / i n v i t e / s e x y t e e n s - text = text.replace(" ", "") - # Remove backslashes to prevent escape character aroundfuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") @@ -240,8 +236,9 @@ class Filtering: response = await response.json() guild = response.get("guild") if guild is None: - # We don't have whitelisted Group DMs so we can - # go ahead and return a positive for any group DM + # Lack of a "guild" key in the JSON response indicates either an group DM invite, an + # expired invite, or an invalid invite. The API does not currently differentiate + # between invalid and expired invites return True guild_id = int(guild.get("id")) -- cgit v1.2.3 From a999fbbead4c86ee77bb727820ccef1bfc6c300e Mon Sep 17 00:00:00 2001 From: sco1 Date: Sat, 29 Dec 2018 21:08:37 -0500 Subject: Remove the cursed trailing whitespace --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index c21b45648..f5811d9d2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -217,7 +217,7 @@ class Filtering: async def _has_invites(self, text: str) -> bool: """ - Returns True if the text contains an invite which is not on the guild_invite_whitelist in + Returns True if the text contains an invite which is not on the guild_invite_whitelist in config.yml Attempts to catch some of common ways to try to cheat the system. @@ -237,7 +237,7 @@ class Filtering: guild = response.get("guild") if guild is None: # Lack of a "guild" key in the JSON response indicates either an group DM invite, an - # expired invite, or an invalid invite. The API does not currently differentiate + # expired invite, or an invalid invite. The API does not currently differentiate # between invalid and expired invites return True -- cgit v1.2.3 From 0577d11fa18f5556b45160a45939bf0e8ba2f580 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 1 Jan 2019 19:43:09 +1000 Subject: Cast int to str, correct buffer align, re-add trailing space --- bot/cogs/eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 9e09b3aa0..8e97a35a2 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -68,7 +68,7 @@ class CodeEval: # then we get the length # and use `str.rjust()` # to indent it. - start = "...:".rjust(len(self.ln) + 2) + start = "...: ".rjust(len(str(self.ln)) + 7) if i == len(lines) - 2: if line.startswith("return"): -- cgit v1.2.3