diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | bot/__main__.py | 4 | ||||
| -rw-r--r-- | bot/cogs/alias.py | 161 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 4 | ||||
| -rw-r--r-- | bot/cogs/bot.py | 53 | ||||
| -rw-r--r-- | bot/cogs/cogs.py | 4 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 2 | ||||
| -rw-r--r-- | bot/cogs/deployment.py | 4 | ||||
| -rw-r--r-- | bot/cogs/eval.py | 5 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 8 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 4 | ||||
| -rw-r--r-- | bot/cogs/site.py | 16 | ||||
| -rw-r--r-- | bot/cogs/snakes.py | 4 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 9 | ||||
| -rw-r--r-- | bot/cogs/superstarify.py (renamed from bot/cogs/hiphopify.py) | 48 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 57 | ||||
| -rw-r--r-- | bot/cogs/wolfram.py | 289 | ||||
| -rw-r--r-- | bot/constants.py | 11 | ||||
| -rw-r--r-- | bot/converters.py | 59 | ||||
| -rw-r--r-- | bot/pagination.py | 185 | ||||
| -rw-r--r-- | config-default.yml | 9 |
21 files changed, 819 insertions, 120 deletions
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 diff --git a/bot/__main__.py b/bot/__main__.py index 602846ded..ab66492bb 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -56,11 +56,12 @@ 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") 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") @@ -72,6 +73,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/alias.py b/bot/cogs/alias.py new file mode 100644 index 000000000..7b342a2d0 --- /dev/null +++ b/bot/cogs/alias.py @@ -0,0 +1,161 @@ +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 <prefix>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 <prefix>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 <prefix>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 <prefix>site home. + """ + + await self.invoke(ctx, "site home") + + @command(name="faq", hidden=True) + async def site_faq_alias(self, ctx): + """ + Alias for invoking <prefix>site faq. + """ + + await self.invoke(ctx, "site faq") + + @command(name="rules", hidden=True) + async def site_rules_alias(self, ctx): + """ + Alias for invoking <prefix>site rules. + """ + + await self.invoke(ctx, "site rules") + + @command(name="reload", hidden=True) + async def reload_cog_alias(self, ctx, *, cog_name: str): + """ + Alias for invoking <prefix>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 <prefix>defcon enable. + """ + + await self.invoke(ctx, "defcon enable") + + @command(name="defoff", hidden=True) + async def defcon_disable_alias(self, ctx): + """ + Alias for invoking <prefix>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 <prefix>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 <prefix>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/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/bot.py b/bot/cogs/bot.py index acbc29f98..846783e08 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 @@ -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): """ @@ -357,25 +356,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 - ) - if has_fixed_codeblock: - bot_message = await after.channel.get_message(self.codeblock_message_ids[after.id]) + 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. 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 is None: + 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 +404,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] 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..8261b0a3b 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,12 +178,15 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx): """Internal commands. Top secret!""" + 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) 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 diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 72efee9a5..9165fe654 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -488,15 +488,19 @@ class Moderation(Scheduler): # 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/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)) 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() 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: diff --git a/bot/cogs/hiphopify.py b/bot/cogs/superstarify.py index 785aedca2..e1cfcc184 100644 --- a/bot/cogs/hiphopify.py +++ b/bot/cogs/superstarify.py @@ -16,7 +16,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class Hiphopify: +class Superstarify: """ A set of commands to moderate terrible nicknames. """ @@ -30,7 +30,7 @@ class Hiphopify: 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. + superstar-prison. If they are not allowed, we will change it back. :return: """ @@ -39,11 +39,11 @@ class Hiphopify: log.debug( f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in hiphop-prison..." + "Checking if the user is in superstar-prison..." ) response = await self.bot.http_session.get( - URLs.site_hiphopify_api, + URLs.site_superstarify_api, headers=self.headers, params={"user_id": str(before.id)} ) @@ -55,7 +55,7 @@ class Hiphopify: return # Nick change was triggered by this event. Ignore. log.debug( - f"{after.display_name} is currently in hiphop-prison. " + 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")) @@ -63,23 +63,23 @@ class Hiphopify: 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. " + "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 hiphop-prison. " + "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='hiphopify', aliases=('force_nick', 'hh')) + @command(name='superstarify', aliases=('force_nick', 'ss')) @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def hiphopify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): + async def superstarify(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 + 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 @@ -89,7 +89,7 @@ class Hiphopify: """ log.debug( - f"Attempting to hiphopify {member.display_name} for {duration}. " + f"Attempting to superstarify {member.display_name} for {duration}. " f"forced_nick is set to {forced_nick}." ) @@ -105,7 +105,7 @@ class Hiphopify: params["forced_nick"] = forced_nick response = await self.bot.http_session.post( - URLs.site_hiphopify_api, + URLs.site_superstarify_api, headers=self.headers, json=params ) @@ -114,7 +114,7 @@ class Hiphopify: if "error_message" in response: log.warning( - "Encountered the following error when trying to hiphopify the user:\n" + "Encountered the following error when trying to superstarify the user:\n" f"{response.get('error_message')}" ) embed.colour = Colour.red() @@ -142,7 +142,7 @@ class Hiphopify: 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"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}**" ) @@ -151,30 +151,30 @@ class Hiphopify: await member.edit(nick=forced_nick) await ctx.send(embed=embed) - @command(name='unhiphopify', aliases=('release_nick', 'uhh')) + @command(name='unsuperstarify', aliases=('release_nick', 'uss')) @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def unhiphopify(self, ctx: Context, member: Member): + 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 unhiphopify + :param member: The member to unsuperstarify """ - log.debug(f"Attempting to unhiphopify the following user: {member.display_name}") + 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_hiphopify_api, + URLs.site_superstarify_api, headers=self.headers, json={"user_id": str(member.id)} ) response = await response.json() - embed.description = "User has been released from hiphop-prison." + embed.description = "User has been released from superstar-prison." embed.title = random.choice(POSITIVE_REPLIES) if "error_message" in response: @@ -182,14 +182,14 @@ class Hiphopify: 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"Error encountered when trying to unsuperstarify {member.display_name}:\n" f"{response}" ) - log.debug(f"{member.display_name} was successfully released from hiphop-prison.") + log.debug(f"{member.display_name} was successfully released from superstar-prison.") await ctx.send(embed=embed) def setup(bot): - bot.add_cog(Hiphopify(bot)) - log.info("Cog loaded: Hiphopify") + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") 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/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..43f03d7bf 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): @@ -394,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 @@ -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/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 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..046c1ea56 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>" @@ -217,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}"] @@ -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" |