diff --git a/.gitignore b/.gitignore index bdb3cdb2..607c5c67 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ MANIFEST .coverage # IDE Ext's -.history/ \ No newline at end of file +.history/ + +# tweet_generator example +examples/tweet_generator/*.ttf \ No newline at end of file diff --git a/.tokeignore b/.tokeignore new file mode 100644 index 00000000..2350d6f7 --- /dev/null +++ b/.tokeignore @@ -0,0 +1,2 @@ +docs/* +tests/* diff --git a/Pipfile b/Pipfile index 09f62e8f..2ead8d82 100644 --- a/Pipfile +++ b/Pipfile @@ -4,9 +4,9 @@ verify_ssl = true name = "pypi" [packages] -websockets = "*" -aiohttp = "*" -Pillow = "*" +websockets = ">=10.0" +aiohttp = ">=3.7.4post0,<4.1.0" +Pillow = "==8.4.0" [dev-packages] flake8 = "==4.0.1" diff --git a/VERSION b/VERSION index aac2daca..51de3305 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.1 \ No newline at end of file +0.13.0 \ No newline at end of file diff --git a/docs/PYPI.md b/docs/PYPI.md index 977de25a..edd61ecd 100644 --- a/docs/PYPI.md +++ b/docs/PYPI.md @@ -1,24 +1,23 @@ # Pincer - - [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Pincer-org/pincer/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/Pincer-org/pincer/?branch=main) [![Build Status](https://scrutinizer-ci.com/g/Pincer-org/Pincer/badges/build.png?b=main)](https://scrutinizer-ci.com/g/Pincer-org/Pincer/build-status/main) -![GitHub repo size](https://img.shields.io/github/repo-size/Pincer-org/Pincer) +[![Documentation Status](https://readthedocs.org/projects/pincer/badge/?version=latest)](https://pincer.readthedocs.io/en/latest/?badge=latest) +[![codecov](https://codecov.io/gh/Pincer-org/Pincer/branch/main/graph/badge.svg?token=T15T34KOQW)](https://codecov.io/gh/Pincer-org/Pincer) +![Lines of code](https://tokei.rs/b1/github/pincer-org/pincer?category=code&path=pincer) ![GitHub last commit](https://img.shields.io/github/last-commit/Pincer-org/Pincer) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/Pincer-org/Pincer) ![GitHub](https://img.shields.io/github/license/Pincer-org/Pincer) -![Code Style](https://img.shields.io/badge/code%20style-pep8-green) ![Discord](https://img.shields.io/discord/881531065859190804) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) An asynchronous Python API wrapper meant to replace discord.py -## The package is currently within the planning phase +## The package is currently within the pre-alpha phase ## ๐Ÿ“Œ Links @@ -122,8 +121,8 @@ You have the possibility to use your own class to inherit from the Pincer bot base. ```py -from pincer import Client, command, Descripted - +from pincer import Client +from pincer.commands import command, CommandArg, Description class Bot(Client): def __init__(self) -> None: @@ -139,10 +138,10 @@ class Bot(Client): @command(description="Add two numbers!") async def add( - self, - first: Descripted[int, "The first number"], - second: Descripted[int, "The second number"] - ): + self, + first: CommandArg[int, Description["The first number"]], + second: CommandArg[int, Description["The second number"]] + ): return f"The addition of `{first}` and `{second}` is `{first + second}`" ``` diff --git a/docs/README.md b/docs/README.md index cccf24c1..ab1163a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ [![Build Status](https://scrutinizer-ci.com/g/Pincer-org/Pincer/badges/build.png?b=main)](https://scrutinizer-ci.com/g/Pincer-org/Pincer/build-status/main) [![Documentation Status](https://readthedocs.org/projects/pincer/badge/?version=latest)](https://pincer.readthedocs.io/en/latest/?badge=latest) [![codecov](https://codecov.io/gh/Pincer-org/Pincer/branch/main/graph/badge.svg?token=T15T34KOQW)](https://codecov.io/gh/Pincer-org/Pincer) -![Lines of code](https://img.shields.io/tokei/lines/github/Pincer-org/Pincer) +![Lines of code](https://tokei.rs/b1/github/pincer-org/pincer?category=code&path=pincer) ![GitHub last commit](https://img.shields.io/github/last-commit/Pincer-org/Pincer) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/Pincer-org/Pincer) ![GitHub](https://img.shields.io/github/license/Pincer-org/Pincer) @@ -128,7 +128,8 @@ You have the possibility to use your own class to inherit from the Pincer bot base. ```py -from pincer import Client, command, Descripted +from pincer import Client +from pincer.commands import command, CommandArg, Description class Bot(Client): @@ -148,16 +149,16 @@ class Bot(Client): # pincer.objects.User - User # pincer.objects.Channel - Channel # pincer.objects.Role - Role - # Mentionable is not implemented + # pincer.objects.Mentionable - Mentionable async def say(self, message: str): return message @command(description="Add two numbers!") async def add( - self, - first: Descripted[int, "The first number"], - second: Descripted[int, "The second number"] - ): + self, + first: CommandArg[int, Description["The first number"]], + second: CommandArg[int, Description["The second number"]] + ): return f"The addition of `{first}` and `{second}` is `{first + second}`" ``` diff --git a/docs/api.rst b/docs/api.rst index e4b9c5d1..36e88733 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,6 +9,7 @@ Subpages pincer pincer.core + pincer.commands pincer.middleware pincer.objects pincer.utils \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index c1e59241..92df6c3e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,4 +48,12 @@ installing quickstart tutorial/index - api \ No newline at end of file + api + +.. toctree:: + :maxdepth: 10 + :caption: Reference + :hidden: + + Github repository + Discord server diff --git a/docs/pincer.commands.rst b/docs/pincer.commands.rst new file mode 100644 index 00000000..949c0dbb --- /dev/null +++ b/docs/pincer.commands.rst @@ -0,0 +1,38 @@ + +.. currentmodule:: pincer.commands + +Pincer Commands Module +================== + +Commands +-------- + +command +~~~~~~~ + +.. autofunction:: command + :decorator: +.. autofunction:: message_command + :decorator: +.. autofunction:: user_command + :decorator: + +Command Types +------------- + +.. autoclass:: Modifier() +.. autoclass:: Description() +.. autoclass:: Choices() +.. autoclass:: Choice() +.. autoclass:: MaxValue() +.. autoclass:: MinValue() +.. autoclass:: ChannelTypes() + + + +ChatCommandHandler +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: ChatCommandHandler + +.. autoclass:: ChatCommandHandler() diff --git a/docs/pincer.core.rst b/docs/pincer.core.rst index 11fdf26a..d42318f6 100644 --- a/docs/pincer.core.rst +++ b/docs/pincer.core.rst @@ -47,3 +47,17 @@ HTTPClient .. autoclass:: HTTPClient() :exclude-members: __send, __handle_response + +Rate Limiting +------------- + +Bucket +~~~~~~ + +.. autoclass:: Bucket() + +RateLimiter +~~~~~~~~~~~ + +.. attributetable:: RateLimiter +.. autoclass:: RateLimiter() diff --git a/docs/pincer.objects.app.rst b/docs/pincer.objects.app.rst index c7c9efd5..c717d157 100644 --- a/docs/pincer.objects.app.rst +++ b/docs/pincer.objects.app.rst @@ -164,3 +164,10 @@ DefaultThrottleHandler .. attributetable:: DefaultThrottleHandler .. autoclass:: DefaultThrottleHandler() + +Mentionable +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: Mentionable + +.. autoclass:: Mentionable() diff --git a/docs/pincer.rst b/docs/pincer.rst index bf1320b0..29cf1dee 100644 --- a/docs/pincer.rst +++ b/docs/pincer.rst @@ -17,22 +17,6 @@ Client .. automethod:: Client.event() :decorator: -Commands --------- - -command -~~~~~~~ - -.. autofunction:: command - :decorator: - -ChatCommandHandler -~~~~~~~~~~~~~~~~~~ - -.. attributetable:: ChatCommandHandler - -.. autoclass:: ChatCommandHandler() - Exceptions ---------- diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6d6619f5..e557e5fe 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -58,7 +58,7 @@ Available types are as follows: - pincer.objects.User - User - pincer.objects.Channel - Channel - pincer.objects.Role - Role -- Mentionable is not implemented +- pincer.objects.Mentionable - Mentionable .. code-block:: python diff --git a/examples/chat_commands_class_based.py b/examples/chat_commands_class_based.py index b4226ab9..02875f00 100644 --- a/examples/chat_commands_class_based.py +++ b/examples/chat_commands_class_based.py @@ -1,4 +1,5 @@ -from pincer import Client, command, Descripted +from pincer import Client +from pincer.commands import command, CommandArg, Description from pincer.objects import Message, InteractionFlags, Embed @@ -16,34 +17,40 @@ async def say(self, message: str): @command(description="Add two numbers!") async def add( - self, - first: Descripted[int, "The first number"], - second: Descripted[int, "The second number"] + self, + first: CommandArg[int, Description["The first number"]], + second: CommandArg[int, Description["The second number"]], ): return f"The addition of `{first}` and `{second}` is `{first + second}`" @command(guild=1324567890) - async def private_say(self, message: Descripted[str, "The content of the message"]): + async def private_say( + self, + message: CommandArg[str, Description["The content of the message"]], + ): return Message(message, flags=InteractionFlags.EPHEMERAL) @command(description="How to make embed!") async def pincer_embed(self): - return Embed( - title="Pincer - 0.6.4", - description=( - "๐Ÿš€ An asynchronous python API wrapper meant to replace" - " discord.py\n> Snappy discord api wrapper written " - "with aiohttp & websockets" + return ( + Embed( + title="Pincer - 0.6.4", + description=( + "๐Ÿš€ An asynchronous python API wrapper meant to replace" + " discord.py\n> Snappy discord api wrapper written " + "with aiohttp & websockets" + ), + ) + .add_field( + name="**Github Repository**", + value="> https://github.com/Pincer-org/Pincer", ) - ).add_field( - name="**Github Repository**", - value="> https://github.com/Pincer-org/Pincer" - ).set_thumbnail( - url="https://pincer.dev/img/icon.png" - ).set_image( - url=( - "https://repository-images.githubusercontent.com" - "/400871418/045ebf39-7c6e-4c3a-b744-0c3122374203" + .set_thumbnail(url="https://pincer.dev/img/icon.png") + .set_image( + url=( + "https://repository-images.githubusercontent.com" + "/400871418/045ebf39-7c6e-4c3a-b744-0c3122374203" + ) ) ) diff --git a/examples/chat_commands_function_based.py b/examples/chat_commands_function_based.py index 89fd4542..37d1d9fe 100644 --- a/examples/chat_commands_function_based.py +++ b/examples/chat_commands_function_based.py @@ -1,4 +1,5 @@ -from pincer import command, Client, Descripted +from pincer import Client +from pincer.commands import command, CommandArg, Description from pincer.objects import Message, InteractionFlags, Embed @@ -11,41 +12,48 @@ async def on_ready(self): @command(description="Say something as the bot!") -async def say(message: Descripted[str, "The content of the message"]): +async def say( + message: CommandArg[str, Description["The content of the message"]] +): return message @command(description="Add two numbers!") async def add( - first: Descripted[int, "The first number"], - second: Descripted[int, "The second number"] + first: CommandArg[int, Description["The first number"]], + second: CommandArg[int, Description["The second number"]], ): return f"The addition of `{first}` and `{second}` is `{first + second}`" @command(guild=1324567890) -async def private_say(message: Descripted[str, "The content of the message"]): +async def private_say( + message: CommandArg[str, Description["The content of the message"]] +): return Message(message, flags=InteractionFlags.EPHEMERAL) @command(description="How to make embed!") async def pincer_embed(): - return Embed( - title="Pincer - 0.6.4", - description=( - "๐Ÿš€ An asynchronous python API wrapper meant to replace" - " discord.py\n> Snappy discord api wrapper written " - "with aiohttp & websockets" + return ( + Embed( + title="Pincer - 0.6.4", + description=( + "๐Ÿš€ An asynchronous python API wrapper meant to replace" + " discord.py\n> Snappy discord api wrapper written " + "with aiohttp & websockets" + ), + ) + .add_field( + name="**Github Repository**", + value="> https://github.com/Pincer-org/Pincer", ) - ).add_field( - name="**Github Repository**", - value="> https://github.com/Pincer-org/Pincer" - ).set_thumbnail( - url="https://pincer.dev/img/icon.png" - ).set_image( - url=( - "https://repository-images.githubusercontent.com" - "/400871418/045ebf39-7c6e-4c3a-b744-0c3122374203" + .set_thumbnail(url="https://pincer.dev/img/icon.png") + .set_image( + url=( + "https://repository-images.githubusercontent.com" + "/400871418/045ebf39-7c6e-4c3a-b744-0c3122374203" + ) ) ) diff --git a/examples/context.py b/examples/context.py index a25107f4..49c07379 100644 --- a/examples/context.py +++ b/examples/context.py @@ -1,18 +1,17 @@ -from pincer import Client, command, Descripted -from pincer.objects import Embed +from pincer import Client +from pincer.commands import command, CommandArg, Description +from pincer.objects import Embed, MessageContext class Bot(Client): - @command(description="Say something as the bot!") async def say( - self, ctx, - content: Descripted[str, "The content of the message"] + self, + ctx: MessageContext, + content: CommandArg[str, Description["The content of the message"]], ) -> Embed: # Using the ctx to get the command author - return Embed( - description=f"{ctx.author.user.mention} said {content}" - ) + return Embed(description=f"{ctx.author.user.mention} said {content}") @Client.event async def on_ready(self): diff --git a/examples/cooldowns.py b/examples/cooldowns.py index 68d131a2..c5b4d7ed 100644 --- a/examples/cooldowns.py +++ b/examples/cooldowns.py @@ -51,25 +51,27 @@ async def on_ready(self): @command( # We don't want to send too many requests to our `MEME_URL` so # lets use cooldowns! - # Only allow one request cooldown=1, # For every three seconds cooldown_scale=3, - # And just to make things more clear for our user on what this # command does, lets define a description! - description="Get a random meme!" + description="Get a random meme!", ) async def meme(self): # Fetch our caption and image from our `MEME_URL`. caption, image = await self.get_meme() # Respond with an embed which contains the meme and caption! - return Embed(caption, color=self.random_color()) \ - .set_image(image) \ - .set_footer("Provided by some-random-api.ml", - "https://i.some-random-api.ml/logo.png") + return ( + Embed(caption, color=self.random_color()) + .set_image(image) + .set_footer( + "Provided by some-random-api.ml", + "https://i.some-random-api.ml/logo.png", + ) + ) @Client.event async def on_command_error(self, ctx: MessageContext, error: Exception): @@ -82,14 +84,14 @@ async def on_command_error(self, ctx: MessageContext, error: Exception): return Message( embeds=[ Embed( - "Oops...", - f"The `{ctx.command.app.name}` command can only be used" - f" `{ctx.command.cooldown}` time*(s)* every " - f"`{ctx.command.cooldown_scale}` second*(s)*!", - self.random_color() + title="Oops...", + description=f"The `{ctx.command.app.name}` command can " + f"only be used `{ctx.command.cooldown}` time*(s)* every" + f" `{ctx.command.cooldown_scale}` second*(s)*!", + color=self.random_color(), ) ], - flags=InteractionFlags.EPHEMERAL + flags=InteractionFlags.EPHEMERAL, ) # Oh no, it wasn't a cooldown error. Lets throw it! diff --git a/examples/guessing_game.py b/examples/guessing_game.py index dccdfa08..4cc4054d 100644 --- a/examples/guessing_game.py +++ b/examples/guessing_game.py @@ -6,21 +6,27 @@ class Bot(Client): - @command() # note that the parenthesis are optional async def guess(self, ctx: MessageContext, biggest_number: int): + await ctx.reply( + f"Starting the guessing game!" + f" Pick a number between 0 and {biggest_number}." + ) - await ctx.reply(f"Starting the guessing game! Pick a number between 0 and {biggest_number}.") channel = await self.get_channel(ctx.channel_id) number = random.randint(0, biggest_number) try: - async for next_message, in self.loop_for('on_message', loop_timeout=60): + async for next_message, in self.loop_for( + "on_message", loop_timeout=60 + ): if next_message.author.bot: continue if not next_message.content.isdigit(): - await channel.send(f"{next_message.content} is not a number. Try again!") + await channel.send( + f"{next_message.content} is not a number. Try again!" + ) continue guessed_number = int(next_message.content) diff --git a/examples/tweet_generator/tweet_generator.py b/examples/tweet_generator/tweet_generator.py new file mode 100644 index 00000000..1ffdeb23 --- /dev/null +++ b/examples/tweet_generator/tweet_generator.py @@ -0,0 +1,201 @@ +import re +import textwrap +import os +import sys +from datetime import datetime +from PIL import Image, ImageFont, ImageDraw, ImageOps +from pincer import command, Client +from pincer.commands import CommandArg, Description +from pincer.objects import Message, Embed, MessageContext + + +# you need to manually download the font files and put them into the folder +# ./examples/tweet_generator/ to make the script works using this link: +# https://fonts.google.com/share?selection.family=Noto%20Sans:wght@400;700 +if not all( + font in os.listdir() + for font in [ + "NotoSans-Regular.ttf", + "NotoSans-Bold.ttf" + ] +): + print( + "You don't have the font files installed! you need to manually " + "download the font files and put them into the folder " + "./examples/tweet_generator/ to make the script works using this link: " + "https://fonts.google.com/share?selection.family=Noto%20Sans:wght@400;700" + ) + sys.exit() + + +class Bot(Client): + @Client.event + async def on_ready(self): + print( + f"Started client on {self.bot}\n" + f"Registered commands: {', '.join(self.chat_commands)}" + ) + + @command(description="to create fake tweets") + async def twitter( + self, + ctx: MessageContext, + content: CommandArg[str, Description["The content of the message"]] + ): + await ctx.interaction.ack() + + for text_match, user_id in re.findall( + re.compile(r"(<@!(\d+)>)"), content + ): + content = content.replace( + text_match, f"@{await self.get_user(user_id)}" + ) + + if len(content) > 280: + return "A tweet can be at maximum 280 characters long" + + # download the profile picture and convert it into Image object + avatar = (await ctx.author.user.get_avatar()).resize((128, 128)) + avatar = circular_avatar(avatar) + + # create the tweet by pasting the profile picture into a white image + tweet = trans_paste( + avatar, + Image.new( + "RGBA", + (800, 250 + 50 * len(textwrap.wrap(content, 38))), + (255, 255, 255) + ), + box=(15, 15), + ) + + # add the fonts + font_normal = ImageFont.truetype("NotoSans-Regular.ttf", 40) + font_small = ImageFont.truetype("NotoSans-Regular.ttf", 30) + font_bold = ImageFont.truetype("NotoSans-Bold.ttf", 40) + + # write the name and username on the Image + draw = ImageDraw.Draw(tweet) + draw.text( + (180, 20), + str(ctx.author.user), + fill=(0, 0, 0), + font=font_bold + ) + draw.text( + (180, 70), + f"@{ctx.author.user.username}", + fill=(120, 120, 120), + font=font_normal + ) + + content = add_color_to_mentions(content) + + # write the text + tweet = draw_multicolored_text(tweet, content, font_normal) + + # write the footer + draw.text( + (30, tweet.size[1] - 60), + datetime.now().strftime( + "%I:%M %p ยท %d %b. %Y ยท Twitter for Discord" + ), + fill=(120, 120, 120), + font=font_small, + ) + + return Message( + embeds=[ + Embed(title="Twitter for Discord").set_image( + url="attachment://image0.png" + ) + ], + attachments=[tweet], + ) + + +def trans_paste(fg_img, bg_img, box=(0, 0)): + """ + https://stackoverflow.com/a/53663233/15485584 + paste an image into one another + """ + fg_img_trans = Image.new("RGBA", fg_img.size) + fg_img_trans = Image.blend(fg_img_trans, fg_img, 1.0) + bg_img.paste(fg_img_trans, box, fg_img_trans) + return bg_img + + +def circular_avatar(avatar): + mask = Image.new("L", (128, 128), 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0, 128, 128), fill=255) + avatar = ImageOps.fit(avatar, mask.size, centering=(0.5, 0.5)) + avatar.putalpha(mask) + return avatar + + +def add_color_to_mentions(message): + """ + generate a dict to set were the text need to be in different colors. + if a word starts with '@' it will be write in blue. + + Parameters + ---------- + message: the text + + Returns + ------- + a list with all colors selected + example: + [ + {'color': (0, 0, 0), 'text': 'hello world '}, + {'color': (0, 154, 234), 'text': '@drawbu'} + ] + + """ + message = textwrap.wrap(message, 38) + message = "\n".join(message).split(" ") + result = [] + for word in message: + wordlines = word.splitlines() + for index, text in enumerate(wordlines): + + text += "\n" if index != len(wordlines) - 1 else " " + + if not result: + result.append({"color": (0, 0, 0), "text": text}) + continue + + if not text.startswith("@"): + if result[-1]["color"] == (0, 0, 0): + result[-1]["text"] += text + continue + + result.append({"color": (0, 0, 0), "text": text}) + continue + + result.append({"color": (0, 154, 234), "text": text}) + return result + + +def draw_multicolored_text(image, message, font): + draw = ImageDraw.Draw(image) + x = 30 + y = 170 + y_fontsize = font.getsize(" ")[1] + for text in message: + y -= y_fontsize + for l_index, line in enumerate(text["text"].splitlines()): + if l_index: + x = 30 + y += y_fontsize + draw.text((x, y), line, fill=text["color"], font=font) + x += font.getsize(line)[0] + return image + + +if __name__ == "__main__": + # Of course we have to run our client, you can replace the + # XXXYOURBOTTOKENHEREXXX with your token, or dynamically get it + # through a dotenv/env. + Bot("XXXYOURBOTTOKENHEREXXX").run() diff --git a/packages/dev.txt b/packages/dev.txt index 2ca92243..0c9db3c9 100644 --- a/packages/dev.txt +++ b/packages/dev.txt @@ -1,4 +1,4 @@ -coverage==6.1.2 +coverage==6.2 flake8==4.0.1 tox==3.24.4 pre-commit==2.15.0 diff --git a/pincer/__init__.py b/pincer/__init__.py index caf0bdbc..23885a24 100644 --- a/pincer/__init__.py +++ b/pincer/__init__.py @@ -27,7 +27,6 @@ RateLimitError, GatewayError, ServerError ) from .objects import Intents -from .utils import Choices, Descripted __package__ = "pincer" __title__ = "Pincer library" @@ -56,15 +55,14 @@ def __repr__(self) -> str: ) -version_info = VersionInfo(0, 12, 1) +version_info = VersionInfo(0, 13, 0) __version__ = repr(version_info) __all__ = ( - "BadRequestError", "Bot", "ChatCommandHandler", "Choices", - "Client", "CogAlreadyExists", "CogError", "CogNotFound", - "CommandAlreadyRegistered", "CommandCooldownError", - "CommandDescriptionTooLong", "CommandError", "CommandIsNotCoroutine", - "CommandReturnIsEmpty", "Descripted", "DisallowedIntentsError", + "BadRequestError", "Bot", "ChatCommandHandler", "Client", + "CogAlreadyExists", "CogError", "CogNotFound", "CommandAlreadyRegistered", + "CommandCooldownError", "CommandDescriptionTooLong", "CommandError", + "CommandIsNotCoroutine", "CommandReturnIsEmpty", "DisallowedIntentsError", "DispatchError", "EmbedFieldError", "ForbiddenError", "GatewayConfig", "GatewayError", "HTTPError", "HeartbeatError", "Intents", "InvalidArgumentAnnotation", "InvalidCommandGuild", "InvalidCommandName", diff --git a/pincer/client.py b/pincer/client.py index 0efc9bdd..22429375 100644 --- a/pincer/client.py +++ b/pincer/client.py @@ -21,8 +21,10 @@ ) from .middleware import middleware from .objects import ( - Role, Channel, DefaultThrottleHandler, User, Guild, Intents + Role, Channel, DefaultThrottleHandler, User, Guild, Intents, + GuildTemplate ) +from .utils.conversion import construct_client_dict from .utils.event_mgr import EventMgr from .utils.extraction import get_index from .utils.insertion import should_pass_cls @@ -430,7 +432,7 @@ def execute_event(calls: List[Coro], *args, **kwargs): if should_pass_cls(call): call_args = ( ChatCommandHandler.managers[call.__module__], - *args + *(arg for arg in args if arg is not None) ) ensure_future(call(*call_args, **kwargs)) @@ -488,18 +490,16 @@ async def handle_middleware( ) next_call = get_index(extractable, 0, "") - arguments = get_index(extractable, 1, []) - params = get_index(extractable, 2, {}) + ret_object = get_index(extractable, 1) if next_call is None: raise RuntimeError(f"Middleware `{key}` has not been registered.") - return ( - (next_call, arguments, params) - if next_call.startswith("on_") - else await self.handle_middleware( - payload, next_call, *arguments, **params - ) + if next_call.startswith("on_"): + return (next_call, ret_object) + + return await self.handle_middleware( + payload, next_call, *arguments, **params ) async def execute_error( @@ -546,12 +546,11 @@ async def process_event(self, name: str, payload: GatewayDispatch): what specifically happened. """ try: - key, args, kwargs = await self.handle_middleware(payload, name) - - self.event_mgr.process_events(key, *args) + key, args = await self.handle_middleware(payload, name) + self.event_mgr.process_events(key, args) if calls := self.get_event_coro(key): - self.execute_event(calls, *args, **kwargs) + self.execute_event(calls, args) except Exception as e: await self.execute_error(e) @@ -648,6 +647,60 @@ async def create_guild(self, name: str, **kwargs) -> Guild: g = await self.http.post("guilds", data={"name": name, **kwargs}) return await self.get_guild(g['id']) + async def get_guild_template(self, code: str) -> GuildTemplate: + """|coro| + Retrieves a guild template by its code. + + Parameters + ---------- + code : :class:`str` + The code of the guild template + + Returns + ------- + :class:`~pincer.objects.guild.template.GuildTemplate` + The guild template + """ + return GuildTemplate.from_dict( + construct_client_dict( + self, + await self.http.get(f"guilds/templates/{code}") + ) + ) + + async def create_guild_from_template( + self, + template: GuildTemplate, + name: str, + icon: Optional[str] = None + ) -> Guild: + """|coro| + Creates a guild from a template. + + Parameters + ---------- + template : :class:`~pincer.objects.guild.template.GuildTemplate` + The guild template + name : :class:`str` + Name of the guild (2-100 characters) + icon : Optional[:class:`str`] + base64 128x128 image for the guild icon |default| :data:`None` + + Returns + ------- + :class:`~pincer.objects.guild.guild.Guild` + The created guild + """ + return Guild.from_dict( + construct_client_dict( + self, + await self.http.post( + f"guilds/templates/{template.code}", + data={"name": name, "icon": icon} + ) + ) + ) + async def wait_for( self, event_name: str, diff --git a/pincer/commands/__init__.py b/pincer/commands/__init__.py new file mode 100644 index 00000000..462a7a0d --- /dev/null +++ b/pincer/commands/__init__.py @@ -0,0 +1,13 @@ +# Copyright Pincer 2021-Present +# Full MIT License can be found in `LICENSE` at the project root. + +from .commands import command, user_command, message_command, ChatCommandHandler +from .arg_types import ( + CommandArg, Description, Choice, Choices, ChannelTypes, MaxValue, MinValue +) + +__all__ = ( + "ChannelTypes", "ChatCommandHandler", "Choice", "Choices", + "CommandArg", "Description", "MaxValue", "MinValue", "command", + "message_command", "user_command" +) diff --git a/pincer/commands/arg_types.py b/pincer/commands/arg_types.py new file mode 100644 index 00000000..6822c59d --- /dev/null +++ b/pincer/commands/arg_types.py @@ -0,0 +1,226 @@ +# Copyright Pincer 2021-Present +# Full MIT License can be found in `LICENSE` at the project root. + +from typing import Any, List, Tuple, Union, T + +from ..utils.types import MISSING +from ..objects.app.command import AppCommandOptionChoice + + +class _CommandTypeMeta(type): + def __getitem__(cls, args: Union[Tuple, Any]): + if not isinstance(args, tuple): + args = (args,) + + return cls(*args) + + +class CommandArg(metaclass=_CommandTypeMeta): + """ + Holds the parameters of an application command option + + .. code-block:: python3 + + CommandArg[ + # This is the type of command. + # Supported types are str, int, bool, float, User, Channel, and Role + int, + # The modifiers to the command go here + Description["Pick a number 1-10"], + MinValue[1], + MaxValue[10] + ] + + Parameters + ---------- + command_type : T + The type of the command + \\*args : :class:`~pincer.commands.arg_types.Modifier` + + """ + + def __init__(self, command_type, *args): + self.command_type = command_type + self.modifiers = args + + def get_arg(self, arg_type: T) -> T: + for arg in self.modifiers: + if isinstance(arg, arg_type): + return arg.get_payload() + + return MISSING + + +class Modifier(metaclass=_CommandTypeMeta): + """ + Modifies a CommandArg by being added to + :class:`~pincer.commands.arg_types.CommandArg`'s args. + """ + + +class Description(Modifier): + """ + Represents the description of an application command option + + .. code-block:: python3 + + # Creates an int argument with the description "example description" + CommandArg[ + int, + Description["example description"] + ] + + Parameters + ---------- + desc : str + The description for the command. + """ + + def __init__(self, desc): + self.desc = str(desc) + + def get_payload(self) -> str: + return self.desc + + +class Choice(Modifier): + """ + Represents a choice that the user can pick from + + .. code-block:: python3 + + Choices[ + Choice["First Number", 10], + Choice["Second Number", 20] + ] + + Parameters + ---------- + name : str + The name of the choice + value : Union[int, str, float] + The value of the choice + """ + + def __init__(self, name, value): + self.name = name + self.value = value + + +class Choices(Modifier): + """ + Represents a group of application command choices that a user can pick from + + .. code-block:: python3 + + CommandArg[ + int, + Choices[ + Choice["First Number", 10], + 20, + 50 + ] + ] + + Parameters + ---------- + \\*choices : Union[:class:`~pincer.commands.arg_types.Choice`, str, int, float] + A choice. If the type is not :class:`~pincer.commands.arg_types.Choice`, + the same value will be used for the choice name and value. + """ + + def __init__(self, *choices): + self.choices = [] + + for choice in choices: + if isinstance(choice, Choice): + self.choices.append( + AppCommandOptionChoice(name=choice.name, value=choice.value) + ) + continue + + self.choices.append( + AppCommandOptionChoice(name=str(choice), value=choice) + ) + + def get_payload(self) -> List[AppCommandOptionChoice]: + return self.choices + + +class ChannelTypes(Modifier): + """ + Represents a group of channel types that a user can pick from + + .. code-block:: python3 + + CommandArg[ + Channel, + # The user will only be able to choice between GUILD_TEXT and + GUILD_TEXT channels. + ChannelTypes[ + ChannelType.GUILD_TEXT, + ChannelType.GUILD_VOICE + ] + ] + + Parameters + ---------- + \\*types : :class:`~pincer.objects.guild.channel.ChannelType` + A list of channel types that the user can pick from. + """ + + def __init__(self, *types): + self.types = types + + def get_payload(self): + return self.types + + +class MaxValue(Modifier): + """ + Represents the max value for a number + + .. code-block:: python3 + + CommandArg[ + int, + # The user can't pick a number above 10 + MaxValue[10] + ] + + Parameters + ---------- + max_value : Union[:class:`float`, :class:`int`] + The max value a user can choose. + """ + + def __init__(self, max_value): + self.max_value = max_value + + def get_payload(self): + return self.max_value + + +class MinValue(Modifier): + """ + Represents the minimum value for a number + + .. code-block:: python3 + + CommandArg[ + int, + # The user can't pick a number below 10 + MinValue[10] + ] + + Parameters + ---------- + min_value : Union[:class:`float`, :class:`int`] + The minimum value a user can choose. + """ + + def __init__(self, min_value): + self.min_value = min_value + + def get_payload(self): + return self.min_value diff --git a/pincer/commands.py b/pincer/commands/commands.py similarity index 61% rename from pincer/commands.py rename to pincer/commands/commands.py index 87274f8e..5bb3be47 100644 --- a/pincer/commands.py +++ b/pincer/commands/commands.py @@ -8,12 +8,20 @@ from asyncio import iscoroutinefunction, gather from copy import deepcopy from functools import partial -from inspect import Signature, isasyncgenfunction -from typing import TYPE_CHECKING, get_origin, get_args, Union, Tuple, List +from inspect import Signature, isasyncgenfunction, _empty +from typing import TYPE_CHECKING, Union, Tuple, List from . import __package__ -from .utils.snowflake import Snowflake -from .exceptions import ( +from ..commands.arg_types import ( + ChannelTypes, + CommandArg, + Description, + Choices, + MaxValue, + MinValue, +) +from ..utils.snowflake import Snowflake +from ..exceptions import ( CommandIsNotCoroutine, CommandAlreadyRegistered, TooManyArguments, @@ -23,26 +31,26 @@ InvalidCommandName, ForbiddenError, ) -from .objects import ( +from ..objects import ( ThrottleScope, AppCommand, Role, User, Channel, Guild, + Mentionable, MessageContext, ) -from .objects.app import ( +from ..objects.app import ( AppCommandOptionType, AppCommandOption, - AppCommandOptionChoice, ClientCommandStructure, AppCommandType, ) -from .utils import get_index, should_pass_ctx -from .utils.signature import get_signature_and_params -from .utils.types import Coro, MISSING, choice_value_types, Choices -from .utils.types import Singleton, TypeCache, Descripted +from ..utils import get_index, should_pass_ctx +from ..utils.signature import get_signature_and_params +from ..utils.types import MISSING +from ..utils.types import Singleton if TYPE_CHECKING: from typing import Any, Optional, Dict @@ -61,10 +69,11 @@ User: AppCommandOptionType.USER, Channel: AppCommandOptionType.CHANNEL, Role: AppCommandOptionType.ROLE, + Mentionable: AppCommandOptionType.MENTIONABLE } if TYPE_CHECKING: - from .client import Client + from ..client import Client def command( @@ -78,7 +87,7 @@ def command( cooldown_scale: Optional[float] = 60, cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, ): - """A decorator to create a command to register and respond to + """A decorator to create a slash command to register and respond to with the discord API from a function. str - String @@ -88,7 +97,7 @@ def command( pincer.objects.User - User pincer.objects.Channel - Channel pincer.objects.Role - Role - Mentionable is not implemented + pincer.objects.Mentionable - Mentionable .. code-block:: python3 @@ -99,10 +108,18 @@ class Bot(Client): ) async def test_command( self, - ctx, + ctx: MessageContext, amount: int, - name: Descripted[str, "ah yes"], - letter: Choices["a", "b", "c"] + name: CommandArg[ + str, + Description["Do something cool"], + Choices[Choice["first value", 1], 5] + ], + optional_int: CommandArg[ + int, + MinValue[10], + MaxValue[100], + ] = 50 ): return Message( f"You chose {amount}, {name}, {letter}", @@ -112,9 +129,15 @@ async def test_command( References from above: :class:`~client.Client`, :class:`~objects.message.message.Message`, - :class:`~utils.types.Choices`, - :class:`~utils.types.Descripted`, - :class:`~objects.app.interactions.InteractionFlags` + :class:`~objects.message.context.MessageContext`, + :class:`~pincer.objects.app.interaction_flags.InteractionFlags`, + :class:`~pincer.commands.arg_types.Choices`, + :class:`~pincer.commands.arg_types.Choice`, + :class:`~pincer.commands.arg_types.CommandArg`, + :class:`~pincer.commands.arg_types.Description`, + :class:`~pincer.commands.arg_types.MinValue`, + :class:`~pincer.commands.arg_types.MaxValue` + Parameters ---------- @@ -155,7 +178,6 @@ async def test_command( Annotations must consist of name and value """ # noqa: E501 - if func is None: return partial( command, @@ -168,169 +190,348 @@ async def test_command( cooldown_scope=cooldown_scope, ) - if not iscoroutinefunction(func) and not isasyncgenfunction(func): - raise CommandIsNotCoroutine( - f"Command with call `{func.__name__}` is not a coroutine, " - "which is required for commands." - ) - - cmd = name or func.__name__ - - if not re.match(COMMAND_NAME_REGEX, cmd): - raise InvalidCommandName( - f"Command `{cmd}` doesn't follow the name requirements." - "Ensure to match the following regex:" - f" {COMMAND_NAME_REGEX.pattern}" - ) - - try: - guild_id = Snowflake(guild) if guild else MISSING - except ValueError: - raise InvalidCommandGuild( - f"Command with call `{func.__name__}` its `guilds` parameter " - "contains a non valid guild id." - ) - - if len(description) > 100: - raise CommandDescriptionTooLong( - f"Command `{cmd}` (`{func.__name__}`) its description exceeds " - "the 100 character limit." - ) - - if reg := ChatCommandHandler.register.get(cmd): - raise CommandAlreadyRegistered( - f"Command `{cmd}` (`{func.__name__}`) has already been " - f"registered by `{reg.call.__name__}`." - ) + options: List[AppCommandOption] = [] - sig, params = get_signature_and_params(func) - pass_context = should_pass_ctx(sig, params) + signature, params = get_signature_and_params(func) + pass_context = should_pass_ctx(signature, params) if len(params) > (25 + pass_context): + cmd = name or func.__name__ raise TooManyArguments( f"Command `{cmd}` (`{func.__name__}`) can only have 25 " f"arguments (excluding the context and self) yet {len(params)} " "were provided!" ) - options: List[AppCommandOption] = [] - for idx, param in enumerate(params): if idx == 0 and pass_context: continue - annotation, required = sig[param].annotation, True + sig = signature[param] + + annotation, required = sig.annotation, sig.default is _empty # ctx is type MessageContext but should not be included in the # slash command - if annotation == MessageContext: + if annotation == MessageContext and idx == 1: return - argument_description: Optional[str] = None - choices: List[AppCommandOptionChoice] = [] - - if isinstance(annotation, str): - TypeCache() - annotation = eval(annotation, TypeCache.cache, globals()) - - if isinstance(annotation, Descripted): - argument_description = annotation.description - annotation = annotation.key - - if len(argument_description) > 100: - raise CommandDescriptionTooLong( - f"Tuple annotation `{annotation}` on parameter " - f"`{param}` in command `{cmd}` (`{func.__name__}`), " - "argument description too long. (maximum length is 100 " - "characters)" + if type(annotation) is not CommandArg: + if annotation in _options_type_link: + options.append( + AppCommandOption( + type=_options_type_link[annotation], + name=param, + description="Description not set", + required=required, + ) ) + continue + + # TODO: Write better exception + raise InvalidArgumentAnnotation( + "Type must be CommandArg or other valid type" + ) - if get_origin(annotation) is Union: - args = get_args(annotation) - if type(None) in args: - required = False + command_type = _options_type_link[annotation.command_type] + argument_description = ( + annotation.get_arg(Description) or "Description not set" + ) + choices = annotation.get_arg(Choices) - # Do NOT use isinstance as this is a comparison between - # two values of the type type and isinstance does NOT - # work here. - union_args = [t for t in args if t is not type(None)] + if choices is not MISSING and annotation.command_type not in { + int, + float, + str, + }: + raise InvalidArgumentAnnotation( + "Choice type is only allowed for str, int, and float" + ) + if choices is not MISSING: + for choice in choices: + if ( + isinstance(choice.value, int) + and annotation.command_type is float + ): + continue + if not isinstance(choice.value, annotation.command_type): + raise InvalidArgumentAnnotation( + "Choice value must match the command type" + ) - annotation = ( - get_index(union_args, 0) - if len(union_args) == 1 - else Tuple[List] + channel_types = annotation.get_arg(ChannelTypes) + if ( + channel_types is not MISSING + and annotation.command_type is not Channel + ): + raise InvalidArgumentAnnotation( + "ChannelTypes are only available for Channels" ) - if get_origin(annotation) is Choices: - args = get_args(annotation) + max_value = annotation.get_arg(MaxValue) + min_value = annotation.get_arg(MinValue) - if len(args) > 25: + for i, value in enumerate((min_value, max_value)): + if ( + value is not MISSING + and annotation.command_type is not int + and annotation.command_type is not float + ): + t = ("MinValue", "MaxValue") raise InvalidArgumentAnnotation( - f"Choices/Literal annotation `{annotation}` on " - f"parameter `{param}` in command `{cmd}` " - f"(`{func.__name__}`) amount exceeds limit of 25 items!" + f"{t[i]} is only available for int and float" ) - choice_type = type(args[0]) + options.append( + AppCommandOption( + type=command_type, + name=param, + description=argument_description, + required=required, + choices=choices, + channel_types=channel_types, + max_value=max_value, + min_value=min_value, + ) + ) - if choice_type is Descripted: - choice_type = type(args[0].key) + return register_command( + func=func, + app_command_type=AppCommandType.CHAT_INPUT, + name=name, + description=description, + enable_default=enable_default, + guild=guild, + cooldown=cooldown, + cooldown_scale=cooldown_scale, + cooldown_scope=cooldown_scope, + command_options=options, + ) - for choice in args: - choice_description = choice - if isinstance(choice, Descripted): - choice_description = choice.description - choice = choice.key - if choice_type is tuple: - choice_type = type(choice) +def user_command( + func=None, + *, + name: Optional[str] = None, + enable_default: Optional[bool] = True, + guild: Union[Snowflake, int, str] = None, + cooldown: Optional[int] = 0, + cooldown_scale: Optional[float] = 60, + cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, +): + """A decorator to create a user command registering and responding + to the Discord API from a function. + + .. code-block:: python3 + + class Bot(Client): + @user_command + async def test_user_command( + self, + ctx: MessageContext, + user: User, + member: GuildMember + ): + if not member: + # member is missing if this is a DM + # This bot doesn't like being DMed so it won't respond + return + + return f"Hello {user.name}, this is a Guild." + + + References from above: + :class:`~client.Client`, + :class:`~objects.message.context.MessageContext`, + :class:`~objects.user.user.User`, + :class:`~objects.guild.member.GuildMember`, + + + Parameters + ---------- + name : Optional[:class:`str`] + The name of the command |default| :data:`None` + enable_default : Optional[:class:`bool`] + Whether the command is enabled by default |default| :data:`True` + guild : Optional[Union[:class:`~pincer.utils.snowflake.Snowflake`, :class:`int`, :class:`str`]] + What guild to add it to (don't specify for global) |default| :data:`None` + cooldown : Optional[:class:`int`] + The amount of times in the cooldown_scale the command can be invoked + |default| ``0`` + cooldown_scale : Optional[:class:`float`] + The 'checking time' of the cooldown |default| ``60`` + cooldown_scope : :class:`~pincer.objects.app.throttle_scope.ThrottleScope` + What type of cooldown strategy to use |default| :attr:`ThrottleScope.USER` + + Raises + ------ + CommandIsNotCoroutine + If the command function is not a coro + InvalidCommandName + If the command name does not follow the regex ``^[\\w-]{1,32}$`` + InvalidCommandGuild + If the guild id is invalid + CommandDescriptionTooLong + Descriptions max 100 characters + If the annotation on an argument is too long (also max 100) + CommandAlreadyRegistered + If the command already exists + InvalidArgumentAnnotation + Annotation amount is max 25, + Not a valid argument type, + Annotations must consist of name and value + """ + # noqa: E501 + return register_command( + func=func, + app_command_type=AppCommandType.USER, + name=name, + enable_default=enable_default, + guild=guild, + cooldown=cooldown, + cooldown_scale=cooldown_scale, + cooldown_scope=cooldown_scope, + ) - if type(choice) not in choice_value_types: - # Properly get all the names of the types - valid_types = list( - map(lambda x: x.__name__, choice_value_types) - ) - raise InvalidArgumentAnnotation( - f"Choices/Literal annotation `{annotation}` on " - f"parameter `{param}` in command `{cmd}` " - f"(`{func.__name__}`), invalid type received. " - "Value must be a member of " - f"{', '.join(valid_types)} but " - f"{type(choice).__name__} was given!" - ) - elif not isinstance(choice, choice_type): - raise InvalidArgumentAnnotation( - f"Choices/Literal annotation `{annotation}` on " - f"parameter `{param}` in command `{cmd}` " - f"(`{func.__name__}`), all values must be of the " - "same type!" - ) - choices.append( - AppCommandOptionChoice( - name=choice_description, value=choice - ) - ) +def message_command( + func=None, + *, + name: Optional[str] = None, + enable_default: Optional[bool] = True, + guild: Union[Snowflake, int, str] = None, + cooldown: Optional[int] = 0, + cooldown_scale: Optional[float] = 60, + cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, +): + """A decorator to create a user command to register and respond + to the Discord API from a function. - annotation = choice_type + .. code-block:: python3 - param_type = _options_type_link.get(annotation) + class Bot(Client): + @user_command + async def test_message_command( + self, + ctx: MessageContext, + message: UserMessage, + ): + return message.content - if not param_type: - raise InvalidArgumentAnnotation( - f"Annotation `{annotation}` on parameter " - f"`{param}` in command `{cmd}` (`{func.__name__}`) is not " - "a valid type." - ) - options.append( - AppCommandOption( - type=param_type, - name=param, - description=argument_description or "Description not set", - required=required, - choices=choices or MISSING, - ) + References from above: + :class:`~client.Client`, + :class:`~objects.message.context.MessageContext`, + :class:`~objects.message.message.UserMessage`, + :class:`~objects.user.user.User`, + :class:`~objects.guild.member.GuildMember`, + + + Parameters + ---------- + name : Optional[:class:`str`] + The name of the command |default| :data:`None` + enable_default : Optional[:class:`bool`] + Whether the command is enabled by default |default| :data:`True` + guild : Optional[Union[:class:`~pincer.utils.snowflake.Snowflake`, :class:`int`, :class:`str`]] + What guild to add it to (don't specify for global) |default| :data:`None` + cooldown : Optional[:class:`int`] + The amount of times in the cooldown_scale the command can be invoked + |default| ``0`` + cooldown_scale : Optional[:class:`float`] + The 'checking time' of the cooldown |default| ``60`` + cooldown_scope : :class:`~pincer.objects.app.throttle_scope.ThrottleScope` + What type of cooldown strategy to use |default| :attr:`ThrottleScope.USER` + + Raises + ------ + CommandIsNotCoroutine + If the command function is not a coro + InvalidCommandName + If the command name does not follow the regex ``^[\\w-]{1,32}$`` + InvalidCommandGuild + If the guild id is invalid + CommandDescriptionTooLong + Descriptions max 100 characters + If the annotation on an argument is too long (also max 100) + CommandAlreadyRegistered + If the command already exists + InvalidArgumentAnnotation + Annotation amount is max 25, + Not a valid argument type, + Annotations must consist of name and value + """ + return register_command( + func=func, + app_command_type=AppCommandType.MESSAGE, + name=name, + enable_default=enable_default, + guild=guild, + cooldown=cooldown, + cooldown_scale=cooldown_scale, + cooldown_scope=cooldown_scope, + ) + + +def register_command( + func=None, # Missing typehint? + *, + app_command_type: Optional[AppCommandType] = None, + name: Optional[str] = None, + description: Optional[str] = MISSING, + enable_default: Optional[bool] = True, + guild: Optional[Union[Snowflake, int, str]] = None, + cooldown: Optional[int] = 0, + cooldown_scale: Optional[float] = 60, + cooldown_scope: Optional[ThrottleScope] = ThrottleScope.USER, + command_options=MISSING, # Missing typehint? +): + if func is None: + return partial( + register_command, + name=name, + app_command_type=app_command_type, + description=description, + enable_default=enable_default, + guild=guild, + cooldown=cooldown, + cooldown_scale=cooldown_scale, + cooldown_scope=cooldown_scope, + ) + + if not iscoroutinefunction(func) and not isasyncgenfunction(func): + raise CommandIsNotCoroutine( + f"Command with call `{func.__name__}` is not a coroutine, " + "which is required for commands." + ) + + cmd = name or func.__name__ + + if not re.match(COMMAND_NAME_REGEX, cmd): + raise InvalidCommandName( + f"Command `{cmd}` doesn't follow the name requirements." + " Ensure to match the following regex:" + f" {COMMAND_NAME_REGEX.pattern}" + ) + + try: + guild_id = Snowflake(guild) if guild else MISSING + except ValueError: + raise InvalidCommandGuild( + f"Command with call `{func.__name__}` its `guilds` parameter " + "contains a non valid guild id." + ) + + if description and len(description) > 100: + raise CommandDescriptionTooLong( + f"Command `{cmd}` (`{func.__name__}`) its description exceeds " + "the 100 character limit." + ) + + if reg := ChatCommandHandler.register.get(cmd): + raise CommandAlreadyRegistered( + f"Command `{cmd}` (`{func.__name__}`) has already been " + f"registered by `{reg.call.__name__}`." ) ChatCommandHandler.register[cmd] = ClientCommandStructure( @@ -341,9 +542,9 @@ async def test_command( app=AppCommand( name=cmd, description=description, - type=AppCommandType.CHAT_INPUT, + type=app_command_type, default_permission=enable_default, - options=options, + options=command_options, guild_id=guild_id, ), ) diff --git a/pincer/core/__init__.py b/pincer/core/__init__.py index 0534dca5..ad11074c 100644 --- a/pincer/core/__init__.py +++ b/pincer/core/__init__.py @@ -5,8 +5,10 @@ from .gateway import Dispatcher from .heartbeat import Heartbeat from .http import HTTPClient +from .ratelimiter import RateLimiter, Bucket __all__ = ( - "Dispatcher", "GatewayDispatch", "HTTPClient", "Heartbeat" + "Bucket", "Dispatcher", "GatewayDispatch", "HTTPClient", + "Heartbeat", "RateLimiter" ) diff --git a/pincer/core/http.py b/pincer/core/http.py index 3be933c1..47dde97e 100644 --- a/pincer/core/http.py +++ b/pincer/core/http.py @@ -10,6 +10,7 @@ from aiohttp import ClientSession, ClientResponse from . import __package__ +from .ratelimiter import RateLimiter from .._config import GatewayConfig from ..exceptions import ( NotFoundError, BadRequestError, NotModifiedError, UnauthorizedError, @@ -44,29 +45,31 @@ def __call__( class HTTPClient: """Interacts with Discord API through HTTP protocol + Parameters + ---------- + Instantiate a new HttpApi object. + + token: + Discord API token + + Keyword Arguments: + + version: + The discord API version. + See ``_. + ttl: + Max amount of attempts after error code 5xx + Attributes ---------- url: :class:`str` - ``f"https://discord.com/api/v{version}"`` "Base url for all HTTP requests" + ``f"https://discord.com/api/v{version}"`` + "Base url for all HTTP requests" max_tts: :class:`int` Max amount of attempts after error code 5xx """ def __init__(self, token: str, *, version: int = None, ttl: int = 5): - """ - Instantiate a new HttpApi object. - - token: - Discord API token - - Keyword Arguments: - - version: - The discord API version. - See ``_. - ttl: - Max amount of attempts after error code 5xx - """ version = version or GatewayConfig.version self.url: str = f"https://discord.com/api/v{version}" self.max_ttl: int = ttl @@ -74,6 +77,7 @@ def __init__(self, token: str, *, version: int = None, ttl: int = 5): headers: Dict[str, str] = { "Authorization": f"Bot {token}", } + self.__rate_limiter = RateLimiter() self.__session: ClientSession = ClientSession(headers=headers) self.__http_exceptions: Dict[int, HTTPError] = { @@ -148,6 +152,11 @@ async def __send( # TODO: Adjust to work non-json types _log.debug(f"{method.__name__.upper()} {endpoint} | {data}") + await self.__rate_limiter.wait_until_not_ratelimited( + endpoint, + method + ) + url = f"{self.url}/{endpoint}" async with method( url, @@ -196,6 +205,11 @@ async def __handle_response( (Eg set to 1 for 1 max retry) """ _log.debug(f"Received response for {endpoint} | {await res.text()}") + + self.__rate_limiter.save_response_bucket( + endpoint, method, res.headers + ) + if res.ok: if res.status == 204: _log.debug( @@ -218,6 +232,7 @@ async def __handle_response( _log.exception( f"RateLimitError: {res.reason}." + f" The scope is {res.headers.get('X-RateLimit-Scope')}." f" Retrying in {timeout} seconds" ) await sleep(timeout) diff --git a/pincer/core/ratelimiter.py b/pincer/core/ratelimiter.py new file mode 100644 index 00000000..59568d11 --- /dev/null +++ b/pincer/core/ratelimiter.py @@ -0,0 +1,132 @@ +# Copyright Pincer 2021-Present +# Full MIT License can be found in `LICENSE` at the project root. + +from __future__ import annotations + +from asyncio import sleep +from dataclasses import dataclass +import logging +from time import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Tuple + from .http import HttpCallable + +_log = logging.getLogger(__name__) + + +@dataclass +class Bucket: + """Represents a rate limit bucket + + Attributes + ---------- + limit : int + The number of requests that can be made. + remaining : int + The number of remaining requests that can be made. + reset : float + Epoch time at which rate limit resets. + reset_after : float + Total time in seconds until rate limit resets. + time_cached : float + Time since epoch when this bucket was last saved. + """ + limit: int + remaining: int + reset: float + reset_after: float + time_cached: float + + +class RateLimiter: + """Prevents ``user`` rate limits + Attributes + ---------- + bucket_map : Dict[Tuple[str, :class:`~pincer.core.http.HttpCallable`], str] + Maps endpoints and methods to a rate limit bucket + buckets : Dict[str, :class:`~pincer.core.ratelimiter.Bucket`] + Dictionary of buckets + """ + + def __init__(self) -> None: + self.bucket_map: Dict[Tuple[str, HttpCallable], str] = {} + self.buckets: Dict[str, Bucket] = {} + + def save_response_bucket( + self, + endpoint: str, + method: HttpCallable, + header: Dict + ): + """ + Parameters + ---------- + endpoint : str + The endpoint + method : :class:`~pincer.core.http.HttpCallable` + The method used on the endpoint (Eg. ``Get``, ``Post``, ``Patch``) + header : :class:`aiohttp.typedefs.CIMultiDictProxy` + The headers from the response + """ + bucket_id = header.get("X-RateLimit-Bucket") + + if not bucket_id: + return + + self.bucket_map[endpoint, method] = bucket_id + + self.buckets[bucket_id] = Bucket( + limit=int(header["X-RateLimit-Limit"]), + remaining=int(header["X-RateLimit-Remaining"]), + reset=float(header["X-RateLimit-Reset"]), + reset_after=float(header["X-RateLimit-Reset-After"]), + time_cached=time() + ) + + _log.info( + "Rate limit bucket detected: %s - %r.", + bucket_id, + self.buckets[bucket_id] + ) + + async def wait_until_not_ratelimited( + self, + endpoint: str, + method: HttpCallable + ): + """|coro| + Waits until the response no longer needs to be blocked to prevent a + 429 response because of ``user`` rate limits. + + Parameters + ---------- + endpoint : str + The endpoint + method : :class:`~pincer.core.http.HttpCallable` + The method used on the endpoint (Eg. ``Get``, ``Post``, ``Patch``) + """ + bucket_id = self.bucket_map.get((endpoint, method)) + + if not bucket_id: + return + + bucket = self.buckets[bucket_id] + cur_time = time() + + if bucket.remaining == 0: + sleep_time = cur_time - bucket.time_cached + bucket.reset_after + + _log.info( + "Waiting for %ss until rate limit for bucket %s is over.", + sleep_time, + bucket_id + ) + + await sleep(sleep_time) + + _log.info( + "Message sent. Bucket %s rate limit ended.", + bucket_id + ) diff --git a/pincer/middleware/activity_join.py b/pincer/middleware/activity_join.py index 0b54d9b2..6d7e4b6c 100644 --- a/pincer/middleware/activity_join.py +++ b/pincer/middleware/activity_join.py @@ -24,12 +24,13 @@ async def activity_join_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.activity.ActivityJoinEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.activity.ActivityJoinEvent`] ``on_activity_join`` and an ``ActivityJoinEvent`` - """ - return "on_activity_join", [ + """ # noqa: E501 + return ( + "on_activity_join", ActivityJoinEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/activity_join_request.py b/pincer/middleware/activity_join_request.py index aa8440c7..e19a6ea7 100644 --- a/pincer/middleware/activity_join_request.py +++ b/pincer/middleware/activity_join_request.py @@ -21,12 +21,13 @@ async def activity_join_request_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.user.User`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.user.User`] ``on_activity_join_request`` and a ``User`` """ - return "on_activity_join_request", [ + return ( + "on_activity_join_request", User.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/activity_spectate.py b/pincer/middleware/activity_spectate.py index 68a58ed6..43de0043 100644 --- a/pincer/middleware/activity_spectate.py +++ b/pincer/middleware/activity_spectate.py @@ -24,12 +24,12 @@ async def activity_spectate_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.event.activity.ActivitySpectateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.event.activity.ActivitySpectateEvent`] ``on_activity_spectate`` and an ``ActivitySpectateEvent`` - """ - return "on_activity_spectate", [ - ActivitySpectateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return "on_activity_spectate", ActivitySpectateEvent.from_dict( + construct_client_dict(self, payload.data) + ) def export() -> Coro: diff --git a/pincer/middleware/channel_create.py b/pincer/middleware/channel_create.py index b4b2e200..3ea16a67 100644 --- a/pincer/middleware/channel_create.py +++ b/pincer/middleware/channel_create.py @@ -6,20 +6,18 @@ from typing import TYPE_CHECKING -from ..core.dispatch import GatewayDispatch from ..objects.guild.channel import Channel from ..utils.conversion import construct_client_dict if TYPE_CHECKING: from typing import List, Tuple - from ..core.dispatch import GatewayDispatch def channel_create_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[Channel]]: +) -> Tuple[str, Channel]: """|coro| Middleware for ``on_channel_creation`` event. @@ -34,9 +32,10 @@ def channel_create_middleware( Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] ``on_channel_creation`` and a channel. """ - return "on_channel_creation", [ + return ( + "on_channel_creation", Channel.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/channel_delete.py b/pincer/middleware/channel_delete.py index 2b7c9f12..4a48b5d1 100644 --- a/pincer/middleware/channel_delete.py +++ b/pincer/middleware/channel_delete.py @@ -20,7 +20,7 @@ async def channel_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.channel.Channel`] ``on_channel_delete`` and a ``Channel`` """ @@ -32,7 +32,7 @@ async def channel_delete_middleware(self, payload: GatewayDispatch): if old: guild.channels.remove(old) - return "on_channel_delete", [channel] + return "on_channel_delete", channel def export(): diff --git a/pincer/middleware/channel_pins_update.py b/pincer/middleware/channel_pins_update.py index fa8ffbea..f07c89ec 100644 --- a/pincer/middleware/channel_pins_update.py +++ b/pincer/middleware/channel_pins_update.py @@ -19,13 +19,14 @@ async def channel_pins_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.channel.Channel`] ``on_channel_pins_update`` and a ``Channel`` """ - return "on_channel_pins_update", [ + return ( + "on_channel_pins_update", ChannelPinsUpdateEvent.from_dict(payload.data) - ] + ) def export(): diff --git a/pincer/middleware/channel_update.py b/pincer/middleware/channel_update.py index 6ea8c94d..4f645088 100644 --- a/pincer/middleware/channel_update.py +++ b/pincer/middleware/channel_update.py @@ -20,7 +20,7 @@ async def channel_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.channel.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.channel.channel.Channel`] ``on_channel_update`` and a ``Channel`` """ @@ -33,7 +33,7 @@ async def channel_update_middleware(self, payload: GatewayDispatch): guild.channels.remove(old) guild.channels.append(channel) - return "on_channel_update", [channel] + return "on_channel_update", channel def export(): diff --git a/pincer/middleware/error.py b/pincer/middleware/error.py index 0f7dfc4b..063b31ae 100644 --- a/pincer/middleware/error.py +++ b/pincer/middleware/error.py @@ -21,7 +21,7 @@ def error_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[DiscordError]]: +) -> Tuple[str, DiscordError]: """|coro| Middleware for ``on_error`` event. @@ -33,14 +33,15 @@ def error_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.error.DiscordError`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.error.DiscordError`] ``on_error`` and a ``DiscordError`` """ # noqa: E501 - return "on_error", [ + return ( + "on_error", DiscordError.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/guild_ban_add.py b/pincer/middleware/guild_ban_add.py index f400f29a..ca113892 100644 --- a/pincer/middleware/guild_ban_add.py +++ b/pincer/middleware/guild_ban_add.py @@ -21,13 +21,13 @@ async def guild_ban_add_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildBaAddEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildBaAddEvent`] ``on_guild_ban_add_update`` and a ``GuildBanAddEvent`` """ return ( "on_guild_ban_add", - [GuildBanAddEvent.from_dict(construct_client_dict(self, payload.data))], + GuildBanAddEvent.from_dict(construct_client_dict(self, payload.data)), ) diff --git a/pincer/middleware/guild_ban_remove.py b/pincer/middleware/guild_ban_remove.py index c1f75683..7b67362f 100644 --- a/pincer/middleware/guild_ban_remove.py +++ b/pincer/middleware/guild_ban_remove.py @@ -21,13 +21,13 @@ async def guild_ban_remove_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildBanRemoveEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildBanRemoveEvent`] ``on_guild_ban_remove_update`` and a ``GuildBanRemoveEvent`` - """ + """ # noqa: E501 return ( "on_guild_ban_remove", - [GuildBanRemoveEvent.from_dict(construct_client_dict(self, payload.data))], + GuildBanRemoveEvent.from_dict(construct_client_dict(self, payload.data)) ) diff --git a/pincer/middleware/guild_create.py b/pincer/middleware/guild_create.py index 1467041c..0a151231 100644 --- a/pincer/middleware/guild_create.py +++ b/pincer/middleware/guild_create.py @@ -27,13 +27,13 @@ async def guild_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.guild.Guild`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.guild.Guild`] ``on_guild_create`` and a ``Guild`` """ guild = Guild.from_dict(construct_client_dict(self, payload.data)) self.guilds[guild.id] = guild - return "on_guild_create", [guild] + return "on_guild_create", guild def export(): diff --git a/pincer/middleware/guild_delete.py b/pincer/middleware/guild_delete.py index 910b458e..ee533319 100644 --- a/pincer/middleware/guild_delete.py +++ b/pincer/middleware/guild_delete.py @@ -20,18 +20,20 @@ async def guild_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.guild.UnavailableGuild`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.guild.UnavailableGuild`] ``on_guild_delete`` and an ``UnavailableGuild`` """ # TODO: Fix docs on line 23 (three lines above) # http://docs.pincer.dev/pincer.middleware#pincer.middleware.guild_delete.guild_delete_middleware - guild = UnavailableGuild.from_dict(construct_client_dict(self, payload.data)) + guild = UnavailableGuild.from_dict( + construct_client_dict(self, payload.data) + ) if guild.id in self.guilds.key(): self.guilds.pop(guild.id) - return "on_guild_delete", [guild] + return "on_guild_delete", guild def export(): diff --git a/pincer/middleware/guild_emojis_update.py b/pincer/middleware/guild_emojis_update.py index f3b31d77..73016d0e 100644 --- a/pincer/middleware/guild_emojis_update.py +++ b/pincer/middleware/guild_emojis_update.py @@ -21,17 +21,15 @@ async def guild_emojis_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildEmojisUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildEmojisUpdateEvent`] ``on_guild_emoji_update`` and a ``GuildEmojisUpdateEvent`` - """ + """ # noqa: E501 return ( "on_guild_emojis_update", - [ - GuildEmojisUpdateEvent.from_dict( - construct_client_dict(self, payload.data) - ) - ], + GuildEmojisUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) ) diff --git a/pincer/middleware/guild_integrations_update.py b/pincer/middleware/guild_integrations_update.py index eda3bb07..f71f00e6 100644 --- a/pincer/middleware/guild_integrations_update.py +++ b/pincer/middleware/guild_integrations_update.py @@ -21,17 +21,15 @@ async def guild_integrations_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildIntegrationsUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildIntegrationsUpdateEvent`] ``on_guild_integration_update`` and a ``GuildIntegrationsUpdateEvent`` - """ + """ # noqa: E501 return ( "on_guild_integrations_update", - [ - GuildIntegrationsUpdateEvent.from_dict( - construct_client_dict(self, payload.data) - ) - ], + GuildIntegrationsUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) ) diff --git a/pincer/middleware/guild_member_add.py b/pincer/middleware/guild_member_add.py index 7ed761d6..c8f21ce7 100644 --- a/pincer/middleware/guild_member_add.py +++ b/pincer/middleware/guild_member_add.py @@ -24,13 +24,14 @@ async def guild_member_add_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildMemberAddEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildMemberAddEvent`] ``on_guild_member_add`` and a ``GuildMemberAddEvent`` - """ + """ # noqa: E501 - return "on_guild_member_add", [ + return ( + "on_guild_member_add", GuildMemberAddEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/guild_member_remove.py b/pincer/middleware/guild_member_remove.py index f4456970..f7d4f1a6 100644 --- a/pincer/middleware/guild_member_remove.py +++ b/pincer/middleware/guild_member_remove.py @@ -23,13 +23,15 @@ async def guild_member_remove_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildMemberRemoveEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildMemberRemoveEvent`] ``on_guild_member_remove`` and a ``GuildMemberRemoveEvent`` - """ + """ # noqa: E501 return ( "on_guild_member_remove", - [GuildMemberRemoveEvent.from_dict(construct_client_dict(self, payload.data))], + GuildMemberRemoveEvent.from_dict( + construct_client_dict(self, payload.data) + ), ) diff --git a/pincer/middleware/guild_member_update.py b/pincer/middleware/guild_member_update.py index 31376a75..8646af84 100644 --- a/pincer/middleware/guild_member_update.py +++ b/pincer/middleware/guild_member_update.py @@ -24,13 +24,15 @@ async def guild_member_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildMemberUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildMemberUpdateEvent`] ``on_guild_member_update`` and a ``GuildMemberUpdateEvent`` - """ + """ # noqa: E501 return ( "on_guild_member_update", - [GuildMemberUpdateEvent.from_dict(construct_client_dict(self, payload.data))], + GuildMemberUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ), ) diff --git a/pincer/middleware/guild_members_chunk.py b/pincer/middleware/guild_members_chunk.py index 7d91ebcf..1513f128 100644 --- a/pincer/middleware/guild_members_chunk.py +++ b/pincer/middleware/guild_members_chunk.py @@ -24,15 +24,15 @@ async def guild_member_chunk_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildMembersChunkEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildMembersChunkEvent`] ``on_guild_member_chunk`` and a ``GuildMembersChunkEvent`` - """ + """ # noqa: E501 return ( "on_guild_member_chunk", - [GuildMembersChunkEvent.from_dict( + GuildMembersChunkEvent.from_dict( construct_client_dict(self, payload.data) - )] + ) ) diff --git a/pincer/middleware/guild_role_create.py b/pincer/middleware/guild_role_create.py index afacda49..ba3e1714 100644 --- a/pincer/middleware/guild_role_create.py +++ b/pincer/middleware/guild_role_create.py @@ -21,13 +21,15 @@ async def guild_role_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildRoleCreateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildRoleCreateEvent`] ``on_guild_role_create`` and a ``GuildRoleCreateEvent`` - """ + """ # noqa: E501 return ( "on_guild_role_create", - [GuildRoleCreateEvent.from_dict(construct_client_dict(self, payload.data))], + GuildRoleCreateEvent.from_dict( + construct_client_dict(self, payload.data) + ) ) diff --git a/pincer/middleware/guild_role_delete.py b/pincer/middleware/guild_role_delete.py index f3c794c8..0e8f14c9 100644 --- a/pincer/middleware/guild_role_delete.py +++ b/pincer/middleware/guild_role_delete.py @@ -21,13 +21,15 @@ async def guild_role_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildRoleDeleteEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildRoleDeleteEvent`] ``on_guild_role_delete`` and a ``GuildRoleDeleteEvent`` - """ + """ # noqa: E501 return ( "on_guild_role_delete", - [GuildRoleDeleteEvent.from_dict(construct_client_dict(self, payload.data))], + GuildRoleDeleteEvent.from_dict( + construct_client_dict(self, payload.data) + ), ) diff --git a/pincer/middleware/guild_role_update.py b/pincer/middleware/guild_role_update.py index d16b3f33..a32e1cb0 100644 --- a/pincer/middleware/guild_role_update.py +++ b/pincer/middleware/guild_role_update.py @@ -21,13 +21,15 @@ async def guild_role_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildRoleUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildRoleUpdateEvent`] ``on_guild_role_update`` and a ``GuildRoleUpdateEvent`` - """ + """ # noqa: E501 return ( "on_guild_role_update", - [GuildRoleUpdateEvent.from_dict(construct_client_dict(self, payload.data))], + GuildRoleUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ), ) diff --git a/pincer/middleware/guild_status.py b/pincer/middleware/guild_status.py index b51088fd..c46eeb77 100644 --- a/pincer/middleware/guild_status.py +++ b/pincer/middleware/guild_status.py @@ -21,12 +21,13 @@ async def guild_status_middleware(self, payload: GatewayDispatch): Return ------ - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildStatusEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildStatusEvent`] ``on_guild_status`` and a ``GuildStatusEvent`` """ - return "on_guild_status", [ + return ( + "on_guild_status", GuildStatusEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/guild_stickers_update.py b/pincer/middleware/guild_stickers_update.py index dc363821..5b407aae 100644 --- a/pincer/middleware/guild_stickers_update.py +++ b/pincer/middleware/guild_stickers_update.py @@ -21,17 +21,15 @@ async def guild_stickers_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.guild.GuildStickersUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.guild.GuildStickersUpdateEvent`] ``on_guild_sticker_update`` and a ``GuildStickersUpdateEvent`` - """ + """ # noqa: E501 return ( "on_guild_stickers_update", - [ - GuildStickersUpdateEvent.from_dict( - construct_client_dict(self, payload.data) - ) - ], + GuildStickersUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) ) diff --git a/pincer/middleware/guild_update.py b/pincer/middleware/guild_update.py index 9c3f41fe..ca29f0b3 100644 --- a/pincer/middleware/guild_update.py +++ b/pincer/middleware/guild_update.py @@ -21,7 +21,7 @@ async def guild_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.guild.Guild`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.guild.Guild`] ``on_guild_Update`` and an ``Guild`` """ @@ -38,7 +38,7 @@ async def guild_update_middleware(self, payload: GatewayDispatch): )) self.guild[guild.id] = guild - return "on_guild_update", [guild] + return "on_guild_update", guild def export(): diff --git a/pincer/middleware/integration_create.py b/pincer/middleware/integration_create.py index 4ef8fed9..b13cd0bd 100644 --- a/pincer/middleware/integration_create.py +++ b/pincer/middleware/integration_create.py @@ -21,12 +21,15 @@ async def integration_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.events.integration.IntegrationCreateEvent`]] + Tuple[:class:`str`, :class:`~pincer.events.integration.IntegrationCreateEvent`] ``on_integration_create`` and an ``IntegrationCreateEvent`` - """ - return "on_integration_create", [ - IntegrationCreateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_integration_create", + IntegrationCreateEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/integration_delete.py b/pincer/middleware/integration_delete.py index 1de14880..28925022 100644 --- a/pincer/middleware/integration_delete.py +++ b/pincer/middleware/integration_delete.py @@ -21,12 +21,15 @@ async def integration_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.events.integration.IntegrationDeleteEvent`]] + Tuple[:class:`str`, :class:`~pincer.events.integration.IntegrationDeleteEvent`] ``on_integration_delete`` and an ``IntegrationDeleteEvent`` - """ - return "on_integration_delete", [ - IntegrationDeleteEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_integration_delete", + IntegrationDeleteEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/integration_update.py b/pincer/middleware/integration_update.py index bd299175..b3c9da8a 100644 --- a/pincer/middleware/integration_update.py +++ b/pincer/middleware/integration_update.py @@ -21,12 +21,15 @@ async def integration_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.events.integration.IntegrationUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.events.integration.IntegrationUpdateEvent`] ``on_integration_update`` and an ``IntegrationUpdateEvent`` - """ - return "on_integration_update", [ - IntegrationUpdateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_integration_update", + IntegrationUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/interaction_create.py b/pincer/middleware/interaction_create.py index 611d5d69..79c8fd55 100644 --- a/pincer/middleware/interaction_create.py +++ b/pincer/middleware/interaction_create.py @@ -4,17 +4,18 @@ from __future__ import annotations import logging -from inspect import isasyncgenfunction, getfullargspec +from inspect import isasyncgenfunction, _empty from typing import Dict, Any from typing import TYPE_CHECKING + from ..commands import ChatCommandHandler from ..core.dispatch import GatewayDispatch -from ..objects import Interaction, MessageContext +from ..objects import Interaction, MessageContext, AppCommandType from ..utils import MISSING, should_pass_cls, Coro, should_pass_ctx from ..utils import get_index from ..utils.conversion import construct_client_dict -from ..utils.signature import get_params, get_signature_and_params +from ..utils.signature import get_signature_and_params if TYPE_CHECKING: from typing import List, Tuple @@ -28,6 +29,7 @@ async def interaction_response_handler( command: Coro, context: MessageContext, interaction: Interaction, + args: List[Any], kwargs: Dict[str, Any] ): """|coro| @@ -45,16 +47,15 @@ async def interaction_response_handler( \\*\\*kwargs : The arguments to be passed to the command. """ - if should_pass_cls(command): - cls_keyword = getfullargspec(command).args[0] - kwargs[cls_keyword] = ChatCommandHandler.managers[command.__module__] - sig, params = get_signature_and_params(command) if should_pass_ctx(sig, params): - kwargs[params[0]] = context + args.insert(0, context) + + if should_pass_cls(command): + args.insert(0, ChatCommandHandler.managers[command.__module__]) if isasyncgenfunction(command): - message = command(**kwargs) + message = command(*args, **kwargs) async for msg in message: if interaction.has_replied: @@ -62,7 +63,7 @@ async def interaction_response_handler( else: await interaction.reply(msg) else: - message = await command(**kwargs) + message = await command(*args, **kwargs) if not interaction.has_replied: await interaction.reply(message) @@ -88,7 +89,10 @@ async def interaction_handler( """ self.throttler.handle(context) - defaults = {param: None for param in get_params(command)} + sig, _ = get_signature_and_params(command) + + defaults = {key: value.default for key, + value in sig.items() if value.default is not _empty} params = {} if interaction.data.options is not MISSING: @@ -96,17 +100,32 @@ async def interaction_handler( opt.name: opt.value for opt in interaction.data.options } + args = [] + + if interaction.data.type is AppCommandType.USER: + # Add User and Member args + args.append(next(iter(interaction.data.resolved.users.values()))) + + if members := interaction.data.resolved.members: + args.append(next(iter(members.values()))) + else: + args.append(MISSING) + + elif interaction.data.type is AppCommandType.MESSAGE: + # Add Message to args + args.append(next(iter(interaction.data.resolved.messages.values()))) + kwargs = {**defaults, **params} await interaction_response_handler( - self, command, context, interaction, kwargs + self, command, context, interaction, args, kwargs ) async def interaction_create_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[Interaction]]: +) -> Tuple[str, Interaction]: """Middleware for ``on_interaction``, which handles command execution. @@ -124,13 +143,12 @@ async def interaction_create_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.app.interactions.Interaction`]] + Tuple[:class:`str`, :class:`~pincer.objects.app.interactions.Interaction`] ``on_interaction_create`` and an ``Interaction`` - """ # noqa: E501 + """ interaction: Interaction = Interaction.from_dict( construct_client_dict(self, payload.data) ) - await interaction.build() command = ChatCommandHandler.register.get(interaction.data.name) if command: @@ -158,7 +176,7 @@ async def interaction_create_middleware( else: raise e - return "on_interaction_create", [interaction] + return "on_interaction_create", interaction def export() -> Coro: diff --git a/pincer/middleware/invite_create.py b/pincer/middleware/invite_create.py index 2f931bf9..951fd535 100644 --- a/pincer/middleware/invite_create.py +++ b/pincer/middleware/invite_create.py @@ -21,12 +21,13 @@ async def invite_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.invite.InviteCreateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.invite.InviteCreateEvent`] ``on_invite_create`` and an ``InviteCreateEvent`` - """ - return "on_invite_create", [ + """ # noqa: E501 + return ( + "on_invite_create", InviteCreateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/invite_delete.py b/pincer/middleware/invite_delete.py index b12cc3c5..e25900cc 100644 --- a/pincer/middleware/invite_delete.py +++ b/pincer/middleware/invite_delete.py @@ -21,12 +21,13 @@ async def invite_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.events.invite.InviteDeleteEvent`]] + Tuple[:class:`str`, :class:`~pincer.events.invite.InviteDeleteEvent`] ``on_invite_delete`` and an ``InviteDeleteEvent`` """ - return "on_invite_delete", [ + return ( + "on_invite_delete", InviteDeleteEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/message_create.py b/pincer/middleware/message_create.py index 98f7e10b..2db9a65c 100644 --- a/pincer/middleware/message_create.py +++ b/pincer/middleware/message_create.py @@ -18,7 +18,7 @@ async def message_create_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[UserMessage]]: # noqa: E501 +) -> Tuple[str, UserMessage]: # noqa: E501 """|coro| Middleware for ``on_message`` event. @@ -30,12 +30,13 @@ async def message_create_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.message.user_message.UserMessage`]] + Tuple[:class:`str`, :class:`~pincer.objects.message.user_message.UserMessage`] ``on_message`` and a ``UserMessage`` """ # noqa: E501 - return "on_message", [ + return ( + "on_message", UserMessage.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/message_delete.py b/pincer/middleware/message_delete.py index e0d7b804..f79a2e2f 100644 --- a/pincer/middleware/message_delete.py +++ b/pincer/middleware/message_delete.py @@ -18,7 +18,7 @@ async def on_message_delete_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[MessageDeleteEvent]]: +) -> Tuple[str, MessageDeleteEvent]: """|coro| Middleware for ``on_message_delete`` event. @@ -31,11 +31,12 @@ async def on_message_delete_middleware( ------- Tuple[:class:`str`, :class:`~pincer.objects.events.message.MessageDeleteEvent`] ``on_message_delete`` and a ``MessageDeleteEvent`` - """ + """ # noqa: E501 - return "on_message_delete", [ + return ( + "on_message_delete", MessageDeleteEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/message_delete_bulk.py b/pincer/middleware/message_delete_bulk.py index 2f5f9e53..1f6f22ea 100644 --- a/pincer/middleware/message_delete_bulk.py +++ b/pincer/middleware/message_delete_bulk.py @@ -21,12 +21,15 @@ async def message_delete_bulk_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.events.message.MessageDeleteBulkEvent`]] + Tuple[:class:`str`, :class:`~pincer.events.message.MessageDeleteBulkEvent`] ``on_message_delete_bulk`` and an ``MessageDeleteBulkEvent`` """ - return "on_message_delete_bulk", [ - MessageDeleteBulkEvent.from_dict(construct_client_dict(self, payload.data)) - ] + return ( + "on_message_delete_bulk", + MessageDeleteBulkEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/message_reaction_add.py b/pincer/middleware/message_reaction_add.py index c8ee0d92..83283942 100644 --- a/pincer/middleware/message_reaction_add.py +++ b/pincer/middleware/message_reaction_add.py @@ -21,11 +21,12 @@ async def message_reaction_add_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.message.MessageReactionAddEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.message.MessageReactionAddEvent`] ``on_message_reaction_add`` and an ``MessageReactionAddEvent`` - """ + """ # noqa: E501 - return "on_message_reaction_add", [ + return ( + "on_message_reaction_add", MessageReactionAddEvent.from_dict( construct_client_dict( self, @@ -39,7 +40,7 @@ async def message_reaction_add_middleware(self, payload: GatewayDispatch): **payload.data } )) - ] + ) def export(): diff --git a/pincer/middleware/message_reaction_remove.py b/pincer/middleware/message_reaction_remove.py index 6b24367d..2eecc90b 100644 --- a/pincer/middleware/message_reaction_remove.py +++ b/pincer/middleware/message_reaction_remove.py @@ -22,11 +22,12 @@ async def message_reaction_remove_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.message.MessageReactionRemoveEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.message.MessageReactionRemoveEvent`] ``on_message_reaction_remove`` and an ``MessageReactionRemoveEvent`` - """ + """ # noqa: E501 - return "on_message_reaction_remove", [ + return ( + "on_message_reaction_remove", MessageReactionRemoveEvent.from_dict( construct_client_dict( self, @@ -37,7 +38,7 @@ async def message_reaction_remove_middleware(self, payload: GatewayDispatch): **payload.data } )) - ] + ) def export(): diff --git a/pincer/middleware/message_reaction_remove_all.py b/pincer/middleware/message_reaction_remove_all.py index 7aa23a11..9f158deb 100644 --- a/pincer/middleware/message_reaction_remove_all.py +++ b/pincer/middleware/message_reaction_remove_all.py @@ -24,15 +24,16 @@ async def message_reaction_remove_all_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.message.MessageReactionRemoveAllEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.message.MessageReactionRemoveAllEvent`] ``on_message_reaction_remove_all`` and an ``MessageReactionRemoveAllEvent`` - """ + """ # noqa: E501 - return "on_message_reaction_remove_all", [ + return ( + "on_message_reaction_remove_all", MessageReactionRemoveAllEvent.from_dict( construct_client_dict(self, payload.data) ) - ] + ) def export(): diff --git a/pincer/middleware/message_reaction_remove_emoji.py b/pincer/middleware/message_reaction_remove_emoji.py index 3e5ae647..6548f65c 100644 --- a/pincer/middleware/message_reaction_remove_emoji.py +++ b/pincer/middleware/message_reaction_remove_emoji.py @@ -9,7 +9,9 @@ from ..utils.conversion import construct_client_dict -async def message_reaction_remove_emoji_middleware(self, payload: GatewayDispatch): +async def message_reaction_remove_emoji_middleware( + self, payload: GatewayDispatch +): """|coro| Middleware for ``on_message_reaction_remove_emoji`` event. @@ -22,11 +24,12 @@ async def message_reaction_remove_emoji_middleware(self, payload: GatewayDispatc Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.message.MessageReactionRemoveEmojiEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.message.MessageReactionRemoveEmojiEvent`] ``on_message_reaction_remove_emoji`` and an ``MessageReactionRemoveEmojiEvent`` - """ + """ # noqa: E501 - return "on_message_reaction_remove_emoji", [ + return ( + "on_message_reaction_remove_emoji", MessageReactionRemoveEmojiEvent.from_dict( construct_client_dict( self, @@ -37,7 +40,7 @@ async def message_reaction_remove_emoji_middleware(self, payload: GatewayDispatc **payload.data } )) - ] + ) def export(): diff --git a/pincer/middleware/message_update.py b/pincer/middleware/message_update.py index 2411575f..11763c27 100644 --- a/pincer/middleware/message_update.py +++ b/pincer/middleware/message_update.py @@ -19,7 +19,7 @@ async def message_update_middleware( self, payload: GatewayDispatch -) -> Tuple[str, List[UserMessage]]: +) -> Tuple[str, UserMessage]: """|coro| @@ -33,12 +33,13 @@ async def message_update_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.message.user_message.UserMessage`]] + Tuple[:class:`str`, :class:`~pincer.objects.message.user_message.UserMessage`] ``on_message_update`` and a ``UserMessage`` - """ - return "on_message_update", [ + """ # noqa: E501 + return ( + "on_message_update", UserMessage.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/notification_create.py b/pincer/middleware/notification_create.py index 41789f7c..a3ba412f 100644 --- a/pincer/middleware/notification_create.py +++ b/pincer/middleware/notification_create.py @@ -24,14 +24,17 @@ async def notification_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.notification.NotificationCreateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.notification.NotificationCreateEvent`] ``on_notification_create`` and a ``NotificationCreateEvent`` - """ + """ # noqa: E501 channel_id: int = payload.data.get("channel_id") payload.data["message"]["channel_id"] = channel_id - return "on_notification_create", [ - NotificationCreateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + return ( + "on_notification_create", + NotificationCreateEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/payload.py b/pincer/middleware/payload.py index 2c3358c1..6bbcf17b 100644 --- a/pincer/middleware/payload.py +++ b/pincer/middleware/payload.py @@ -13,7 +13,7 @@ async def payload_middleware( payload: GatewayDispatch -) -> Tuple[str, List[GatewayDispatch]]: +) -> Tuple[str, GatewayDispatch]: """Invoked when basically anything is received from gateway. Parameters @@ -24,10 +24,10 @@ async def payload_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.core.dispatch.GatewayDispatch`]] + Tuple[:class:`str`, :class:`~pincer.core.dispatch.GatewayDispatch`] ``on_payload`` and a ``payload`` """ - return "on_payload", [payload] + return "on_payload", payload def export(): diff --git a/pincer/middleware/presence_update.py b/pincer/middleware/presence_update.py index bdf216d1..b1ef9ff8 100644 --- a/pincer/middleware/presence_update.py +++ b/pincer/middleware/presence_update.py @@ -21,12 +21,13 @@ async def presence_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.voice_state.PresenceUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.voice_state.PresenceUpdateEvent`] ``on_presence_update`` and a ``PresenceUpdateEvent`` - """ - return "on_presence_update", [ + """ # noqa: E501 + return ( + "on_presence_update", PresenceUpdateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/speaking_start.py b/pincer/middleware/speaking_start.py index 10351c8e..ccecc88f 100644 --- a/pincer/middleware/speaking_start.py +++ b/pincer/middleware/speaking_start.py @@ -21,12 +21,13 @@ async def speaking_start_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`SpeakingStartEvent`]] + Tuple[:class:`str`, :class:`SpeakingStartEvent`] ``on_speaking_start`` and a ``SpeakingStartEvent`` """ - return "on_speaking_start", [ + return ( + "on_speaking_start", SpeakingStartEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/speaking_stop.py b/pincer/middleware/speaking_stop.py index db4f9e81..d97e142d 100644 --- a/pincer/middleware/speaking_stop.py +++ b/pincer/middleware/speaking_stop.py @@ -21,12 +21,13 @@ async def speaking_stop_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`Snowflake`]] + Tuple[:class:`str`, :class:`Snowflake`] ``on_speaking_stop`` and a ``Snowflake`` (user_id) """ - return "on_speaking_stop", [ + return ( + "on_speaking_stop", SpeakingStopEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/stage_instance_create.py b/pincer/middleware/stage_instance_create.py index 5ff66547..2648e104 100644 --- a/pincer/middleware/stage_instance_create.py +++ b/pincer/middleware/stage_instance_create.py @@ -20,13 +20,14 @@ def stage_instance_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.stage.StageInstance`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.stage.StageInstance`] ``on_stage_instance_create`` and a ``StageInstance`` """ - return "on_stage_instance_create", [ + return ( + "on_stage_instance_create", StageInstance.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/stage_instance_delete.py b/pincer/middleware/stage_instance_delete.py index 1f343d65..ce465adc 100644 --- a/pincer/middleware/stage_instance_delete.py +++ b/pincer/middleware/stage_instance_delete.py @@ -20,13 +20,14 @@ def stage_instance_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.stage.StageInstance`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.stage.StageInstance`] ``on_stage_instance_delete`` and a ``StageInstance`` """ - return "on_stage_instance_delete", [ + return ( + "on_stage_instance_delete", StageInstance.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/stage_instance_update.py b/pincer/middleware/stage_instance_update.py index ebab9d2e..c57f82e9 100644 --- a/pincer/middleware/stage_instance_update.py +++ b/pincer/middleware/stage_instance_update.py @@ -20,13 +20,14 @@ def stage_instance_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.stage.StageInstance`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.stage.StageInstance`] ``on_stage_instance_update`` and a ``StageInstance`` """ - return "on_stage_instance_update", [ + return ( + "on_stage_instance_update", StageInstance.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/thread_create.py b/pincer/middleware/thread_create.py index 0275780d..e10a65b3 100644 --- a/pincer/middleware/thread_create.py +++ b/pincer/middleware/thread_create.py @@ -20,13 +20,14 @@ def thread_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.channel.Channel`] ``on_thread_create`` and an ``Channel`` """ - return "on_thread_create", [ + return ( + "on_thread_create", Channel.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/thread_delete.py b/pincer/middleware/thread_delete.py index 9f8697b2..230ad57f 100644 --- a/pincer/middleware/thread_delete.py +++ b/pincer/middleware/thread_delete.py @@ -20,13 +20,14 @@ async def thread_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.channel.Channel`] ``on_thread_delete`` and an ``Channel`` """ - return "on_thread_delete", [ + return ( + "on_thread_delete", Channel.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/thread_list_sync.py b/pincer/middleware/thread_list_sync.py index a81d26b2..8432f370 100644 --- a/pincer/middleware/thread_list_sync.py +++ b/pincer/middleware/thread_list_sync.py @@ -25,9 +25,9 @@ async def thread_list_sync(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.events.thread.ThreadListSyncEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.events.thread.ThreadListSyncEvent`] ``on_thread_list_sync`` and an ``ThreadListSyncEvent`` - """ + """ # noqa: E501 threads: List[Channel] = [ Channel.from_dict(construct_client_dict(self, thread)) @@ -39,11 +39,12 @@ async def thread_list_sync(self, payload: GatewayDispatch): for member in payload.data.pop("members") ] - return "on_thread_list_sync", [ + return ( + "on_thread_list_sync", ThreadListSyncEvent.from_dict( {"threads": threads, "members": members, **payload.data} ) - ] + ) def export(): diff --git a/pincer/middleware/thread_member_update.py b/pincer/middleware/thread_member_update.py index 077b4052..c15fad58 100644 --- a/pincer/middleware/thread_member_update.py +++ b/pincer/middleware/thread_member_update.py @@ -21,11 +21,12 @@ async def thread_member_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.thread.ThreadMember`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.thread.ThreadMember`] ``on_thread_member_update`` and an ``ThreadMember`` """ - return "on_thread_member_update", [ + return ( + "on_thread_member_update", ThreadMember.from_dict(construct_client_dict( self, { @@ -33,7 +34,7 @@ async def thread_member_update_middleware(self, payload: GatewayDispatch): **payload.data } )) - ] + ) def export(): diff --git a/pincer/middleware/thread_members_update.py b/pincer/middleware/thread_members_update.py index 732b3308..2c9644b8 100644 --- a/pincer/middleware/thread_members_update.py +++ b/pincer/middleware/thread_members_update.py @@ -24,9 +24,9 @@ async def thread_members_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.thread.ThreadMembersUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.thread.ThreadMembersUpdateEvent`] ``on_thread_members_update`` and an ``ThreadMembersUpdateEvent`` - """ + """ # noqa: E501 added_members: List[ThreadMember] = [ ThreadMember.from_dict(construct_client_dict( @@ -39,14 +39,15 @@ async def thread_members_update_middleware(self, payload: GatewayDispatch): for added_member in payload.data.pop("added_members") ] - return "on_thread_members_update", [ + return ( + "on_thread_members_update", ThreadMembersUpdateEvent.from_dict( { "added_members": added_members, **payload.data } ) - ] + ) def export(): diff --git a/pincer/middleware/thread_update.py b/pincer/middleware/thread_update.py index 0b8f2a32..56be14e2 100644 --- a/pincer/middleware/thread_update.py +++ b/pincer/middleware/thread_update.py @@ -21,13 +21,14 @@ async def thread_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.guild.channel.Channel`]] + Tuple[:class:`str`, :class:`~pincer.objects.guild.channel.Channel`] ``on_thread_update`` and an ``Channel`` """ - return "on_thread_update", [ + return ( + "on_thread_update", Channel.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/typing_start.py b/pincer/middleware/typing_start.py index f294123d..dc65c090 100644 --- a/pincer/middleware/typing_start.py +++ b/pincer/middleware/typing_start.py @@ -21,12 +21,13 @@ async def typing_start_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.typing_start.TypingStartEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.typing_start.TypingStartEvent`] ``on_typing_start`` and a ``TypingStartEvent`` - """ - return "on_typing_start", [ + """ # noqa: E501 + return ( + "on_typing_start", TypingStartEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/user_update.py b/pincer/middleware/user_update.py index 3214c404..1731fe67 100644 --- a/pincer/middleware/user_update.py +++ b/pincer/middleware/user_update.py @@ -21,12 +21,13 @@ async def user_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.user.User`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.user.User`] ``on_user_update`` and a ``User`` """ - return "on_user_update", [ + return ( + "on_user_update", User.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/voice_channel_select.py b/pincer/middleware/voice_channel_select.py index 0cac9e22..dc8883e7 100644 --- a/pincer/middleware/voice_channel_select.py +++ b/pincer/middleware/voice_channel_select.py @@ -21,12 +21,15 @@ async def voice_channel_select_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.voice.VoiceChannelSelectEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.voice.VoiceChannelSelectEvent`] ``on_voice_channel_select`` and a ``VoiceChannelSelectEvent`` - """ - return "on_voice_channel_select", [ - VoiceChannelSelectEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_voice_channel_select", + VoiceChannelSelectEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/voice_connection_status.py b/pincer/middleware/voice_connection_status.py index a03f2794..48c9d7cc 100644 --- a/pincer/middleware/voice_connection_status.py +++ b/pincer/middleware/voice_connection_status.py @@ -21,12 +21,15 @@ async def voice_connection_status_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.voice.VoiceConnectionStatusEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.voice.VoiceConnectionStatusEvent`] ``on_voice_connection_status`` and a ``VoiceConnectionStatusEvent`` - """ - return "on_voice_connection_status", [ - VoiceConnectionStatusEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_voice_connection_status", + VoiceConnectionStatusEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/voice_server_update.py b/pincer/middleware/voice_server_update.py index 4aa90272..c98c7c31 100644 --- a/pincer/middleware/voice_server_update.py +++ b/pincer/middleware/voice_server_update.py @@ -21,12 +21,15 @@ async def voice_server_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.voice.VoiceServerUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.voice.VoiceServerUpdateEvent`] ``on_voice_server_update`` and a ``VoiceServerUpdateEvent`` - """ - return "on_voice_server_update", [ - VoiceServerUpdateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_voice_server_update", + VoiceServerUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/voice_settings_update.py b/pincer/middleware/voice_settings_update.py index dac27b09..f0a067b4 100644 --- a/pincer/middleware/voice_settings_update.py +++ b/pincer/middleware/voice_settings_update.py @@ -21,12 +21,15 @@ async def voice_settings_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.VoiceSettingsUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.VoiceSettingsUpdateEvent`] ``on_voice_settings_update`` and a ``VoiceSettingsUpdateEvent`` - """ - return "on_voice_settings_update", [ - VoiceSettingsUpdateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + """ # noqa: E501 + return ( + "on_voice_settings_update", + VoiceSettingsUpdateEvent.from_dict( + construct_client_dict(self, payload.data) + ) + ) def export() -> Coro: diff --git a/pincer/middleware/voice_state_create.py b/pincer/middleware/voice_state_create.py index be8d7c9b..e878aaf2 100644 --- a/pincer/middleware/voice_state_create.py +++ b/pincer/middleware/voice_state_create.py @@ -21,12 +21,13 @@ async def voice_state_create_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.voice_state.VoiceState`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.voice_state.VoiceState`] ``on_voice_state_create`` and a ``VoiceState`` """ - return "on_voice_state_create", [ + return ( + "on_voice_state_create", VoiceState.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/voice_state_delete.py b/pincer/middleware/voice_state_delete.py index 6eb3a336..9c4faf18 100644 --- a/pincer/middleware/voice_state_delete.py +++ b/pincer/middleware/voice_state_delete.py @@ -21,12 +21,13 @@ async def voice_state_delete_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.voice_state.VoiceState`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.voice_state.VoiceState`] ``on_voice_state_delete`` and a ``VoiceState`` """ - return "on_voice_state_delete", [ + return ( + "on_voice_state_delete", VoiceState.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export() -> Coro: diff --git a/pincer/middleware/voice_state_update.py b/pincer/middleware/voice_state_update.py index abc12a2b..cb05abf0 100644 --- a/pincer/middleware/voice_state_update.py +++ b/pincer/middleware/voice_state_update.py @@ -32,14 +32,15 @@ async def voice_state_update_middleware( Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.user.voice_state.VoiceState`]] + Tuple[:class:`str`, :class:`~pincer.objects.user.voice_state.VoiceState`] ``on_voice_state_update`` and a ``VoiceState`` """ # noqa: E501 - return "on_voice_state_update", [ + return ( + "on_voice_state_update", VoiceState.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/middleware/webhooks_update.py b/pincer/middleware/webhooks_update.py index b26175a4..faaaadcd 100644 --- a/pincer/middleware/webhooks_update.py +++ b/pincer/middleware/webhooks_update.py @@ -23,12 +23,13 @@ async def webhooks_update_middleware(self, payload: GatewayDispatch): Returns ------- - Tuple[:class:`str`, List[:class:`~pincer.objects.events.webhook.WebhooksUpdateEvent`]] + Tuple[:class:`str`, :class:`~pincer.objects.events.webhook.WebhooksUpdateEvent`] ``on_webhooks_update`` and a ``WebhooksUpdateEvent`` - """ - return "on_webhooks_update", [ + """ # noqa: E501 + return ( + "on_webhooks_update", WebhooksUpdateEvent.from_dict(construct_client_dict(self, payload.data)) - ] + ) def export(): diff --git a/pincer/objects/__init__.py b/pincer/objects/__init__.py index 126de518..89d23871 100644 --- a/pincer/objects/__init__.py +++ b/pincer/objects/__init__.py @@ -15,6 +15,7 @@ from .app.interactions import ( ResolvedData, InteractionData, Interaction ) +from .app.mentionable import Mentionable from .app.select_menu import SelectOption, SelectMenu from .app.session_start_limit import SessionStartLimit from .app.throttle_scope import ThrottleScope @@ -129,10 +130,10 @@ "IntegrationExpireBehavior", "Intents", "Interaction", "InteractionData", "InteractionFlags", "InteractionType", "Invite", "InviteCreateEvent", "InviteDeleteEvent", "InviteMetadata", "InviteStageInstance", - "InviteTargetType", "MFALevel", "Message", "MessageActivity", - "MessageActivityType", "MessageComponent", "MessageContext", - "MessageDeleteBulkEvent", "MessageDeleteEvent", "MessageFlags", - "MessageInteraction", "MessageReactionAddEvent", + "InviteTargetType", "MFALevel", "Mentionable", "Message", + "MessageActivity", "MessageActivityType", "MessageComponent", + "MessageContext", "MessageDeleteBulkEvent", "MessageDeleteEvent", + "MessageFlags", "MessageInteraction", "MessageReactionAddEvent", "MessageReactionRemoveAllEvent", "MessageReactionRemoveEmojiEvent", "MessageReactionRemoveEvent", "MessageReference", "MessageType", "NewsChannel", "Overwrite", "PartialGuildMember", "PremiumTier", diff --git a/pincer/objects/app/__init__.py b/pincer/objects/app/__init__.py index 3faac163..e3fae1bc 100644 --- a/pincer/objects/app/__init__.py +++ b/pincer/objects/app/__init__.py @@ -12,6 +12,7 @@ from .interaction_base import CallbackType, InteractionType, MessageInteraction from .interaction_flags import InteractionFlags from .interactions import ResolvedData, InteractionData, Interaction +from .mentionable import Mentionable from .select_menu import SelectOption, SelectMenu from .session_start_limit import SessionStartLimit from .throttle_scope import ThrottleScope @@ -23,7 +24,7 @@ "AppCommandOption", "AppCommandOptionChoice", "AppCommandOptionType", "AppCommandType", "Application", "CallbackType", "ClientCommandStructure", "DefaultThrottleHandler", "Intents", "Interaction", "InteractionData", - "InteractionFlags", "InteractionType", "MessageInteraction", + "InteractionFlags", "InteractionType", "Mentionable", "MessageInteraction", "ResolvedData", "SelectMenu", "SelectOption", "SessionStartLimit", "ThrottleInterface", "ThrottleScope" ) diff --git a/pincer/objects/app/command.py b/pincer/objects/app/command.py index 17650ac1..96037b63 100644 --- a/pincer/objects/app/command.py +++ b/pincer/objects/app/command.py @@ -6,7 +6,9 @@ from dataclasses import dataclass from typing import List, Union, TYPE_CHECKING + from .command_types import AppCommandOptionType, AppCommandType +from ...objects.guild.channel import ChannelType from ...utils.api_object import APIObject from ...utils.snowflake import Snowflake from ...utils.types import Coro, choice_value_types @@ -81,9 +83,13 @@ class AppCommandOption(APIObject): name: str description: str - required: APINullable[bool] = False + required: bool = False + autocomplete: APINullable[bool] = MISSING choices: APINullable[List[AppCommandOptionChoice]] = MISSING options: APINullable[List[AppCommandOption]] = MISSING + channel_types: APINullable[List[ChannelType]] = MISSING + min_value: APINullable[Union[int, float]] = MISSING + max_value: APINullable[Union[int, float]] = MISSING @dataclass @@ -136,7 +142,8 @@ class AppCommand(APIObject): def __post_init__(self): super().__post_init__() - self.options = [] if self.options is MISSING else self.options + if self.options is MISSING and self.type is AppCommandType.MESSAGE: + self.options = [] def __eq__(self, other: Union[AppCommand, ClientCommandStructure]): if isinstance(other, ClientCommandStructure): diff --git a/pincer/objects/app/interactions.py b/pincer/objects/app/interactions.py index bea54ae0..5dcf9433 100644 --- a/pincer/objects/app/interactions.py +++ b/pincer/objects/app/interactions.py @@ -3,12 +3,13 @@ from __future__ import annotations -from asyncio import gather, iscoroutine, sleep, ensure_future +from asyncio import sleep, ensure_future from dataclasses import dataclass -from typing import Dict, TYPE_CHECKING, Union, Optional, List +from typing import Any, Dict, TYPE_CHECKING, Union, Optional, List from .command_types import AppCommandOptionType from .interaction_base import InteractionType, CallbackType +from .mentionable import Mentionable from ..app.select_menu import SelectOption from ..guild.member import GuildMember from ..message.context import MessageContext @@ -17,7 +18,7 @@ from ..user import User from ...exceptions import InteractionDoesNotExist, UseFollowup, \ InteractionAlreadyAcknowledged, NotFoundError, InteractionTimedOut -from ...utils import APIObject, convert +from ...utils import APIObject from ...utils.convert_message import convert_message from ...utils.snowflake import Snowflake from ...utils.types import MISSING @@ -142,61 +143,67 @@ class Interaction(APIObject): def __post_init__(self): super().__post_init__() - self._convert_functions = { - AppCommandOptionType.SUB_COMMAND: None, - AppCommandOptionType.SUB_COMMAND_GROUP: None, - - AppCommandOptionType.STRING: str, - AppCommandOptionType.INTEGER: int, - AppCommandOptionType.BOOLEAN: bool, - AppCommandOptionType.NUMBER: float, - - AppCommandOptionType.USER: lambda value: - self._client.get_user( - convert(value, Snowflake.from_string) - ), - AppCommandOptionType.CHANNEL: lambda value: - self._client.get_channel( - convert(value, Snowflake.from_string) - ), - AppCommandOptionType.ROLE: lambda value: - self._client.get_role( - convert(self.guild_id, Snowflake.from_string), - convert(value, Snowflake.from_string) - ), - AppCommandOptionType.MENTIONABLE: None - } - - async def build(self): - """|coro| - - Sets the parameters in the interaction that need information - from the discord API. - """ if not self.data.options: return - await gather( - *map(self.convert, self.data.options) - ) - - async def convert(self, option: AppCommandInteractionDataOption): - """|coro| - - Sets an ``AppCommandInteractionDataOption`` value parameter to - the payload type + for option in self.data.options: + if option.type is AppCommandOptionType.STRING: + option.value = str(option.value) + elif option.type is AppCommandOptionType.INTEGER: + option.value = int(option.value) + elif option.type is AppCommandOptionType.BOOLEAN: + option.value = bool(option.value) + elif option.type is AppCommandOptionType.NUMBER: + option.value = float(option.value) + + elif option.type is AppCommandOptionType.USER: + user = self.return_type(option, self.data.resolved.members) + user.set_user_data( + self.return_type(option, self.data.resolved.users) + ) + option.value = user + + elif option.type is AppCommandOptionType.CHANNEL: + option.value = self.return_type( + option, self.data.resolved.channels + ) + + elif option.type is AppCommandOptionType.ROLE: + option.value = self.return_type( + option, self.data.resolved.roles + ) + + elif option.type is AppCommandOptionType.MENTIONABLE: + user = self.return_type(option, self.data.resolved.members) + if user: + user.set_user_data(self.return_type( + option, self.data.resolved.users) + ) + + option.value = Mentionable( + user, + self.return_type( + option, self.data.resolved.roles + ) + ) + + @staticmethod + def return_type( + option: Snowflake, + data: Dict[Snowflake, Any] + ) -> Optional[APIObject]: """ - converter = self._convert_functions.get(option.type) + Returns a value from the option or None if it doesn't exist. - if not converter: - raise NotImplementedError( - f"Handling for AppCommandOptionType {option.type} is not " - "implemented" - ) - - res = converter(option.value) + option : :class:`~pincer.utils.types.Snowflake` + Snowflake to search ``data`` for. + data : Dict[:class:`~pincer.utils.types.Snowflake`, Any] + Resolved data to search through. + """ + if data: + return data[option.value] - option.value = (await res) if iscoroutine(res) else res + return None def convert_to_message_context(self, command): return MessageContext( diff --git a/pincer/objects/app/mentionable.py b/pincer/objects/app/mentionable.py new file mode 100644 index 00000000..6a650dc5 --- /dev/null +++ b/pincer/objects/app/mentionable.py @@ -0,0 +1,33 @@ +# Copyright Pincer 2021-Present +# Full MIT License can be found in `LICENSE` at the project root. + +from dataclasses import dataclass +from typing import Optional + + +from ...objects.guild.role import Role +from ...objects.user.user import User + + +@dataclass +class Mentionable: + """ + Represents the Mentionable type + + user : Optional[:class:`~pincer.objects.user.user.User`] + User object returned from a discord interaction + role: Optional[:class:`~pincer.objects.guild.role.Role`] + Role object returned from a discord interaction + """ + user: Optional[User] = None + role: Optional[Role] = None + + @property + def is_user(self): + """Returns true if the Mentionable object has a User""" + return self.user is not None + + @property + def is_role(self): + """Returns true if the Mentionable object has a Role""" + return self.role is not None diff --git a/pincer/objects/guild/guild.py b/pincer/objects/guild/guild.py index b397588a..c22362b8 100644 --- a/pincer/objects/guild/guild.py +++ b/pincer/objects/guild/guild.py @@ -7,6 +7,7 @@ from enum import IntEnum from typing import AsyncGenerator, overload, TYPE_CHECKING +from .channel import Channel from ...exceptions import UnavailableGuildError from ...utils.api_object import APIObject from ...utils.conversion import construct_client_dict, remove_none @@ -15,15 +16,16 @@ if TYPE_CHECKING: from typing import Any, Dict, List, Optional, Union + from .audit_log import AuditLog from .ban import Ban - from .channel import Channel from .invite import Invite from .member import GuildMember - from .widget import GuildWidget from .features import GuildFeature from .role import Role from .stage import StageInstance + from .template import GuildTemplate from .welcome_screen import WelcomeScreen, WelcomeScreenChannel + from .widget import GuildWidget from ..user.user import User from ..user.integration import Integration from ..voice.region import VoiceRegion @@ -537,7 +539,7 @@ async def get_roles(self) -> AsyncGenerator[Role, None]: """|coro| Fetches all the roles in the guild. - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.guild.role.Role`, :data:`None`] An async generator of Role objects. @@ -628,7 +630,7 @@ async def edit_role_position( position : Optional[:class:`int`] Sorting position of the role |default| :data:`None` - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.guild.role.Role`, :data:`None`] An async generator of all of the guild's role objects. @@ -729,7 +731,7 @@ async def get_bans(self) -> AsyncGenerator[Ban, None]: """|coro| Fetches all the bans in the guild. - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.guild.ban.Ban`, :data:`None`] An async generator of Ban objects. @@ -951,7 +953,7 @@ async def get_voice_regions(self) -> AsyncGenerator[VoiceRegion, None]: """|coro| Returns an async generator of voice regions. - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.voice.VoiceRegion`, :data:`None`] An async generator of voice regions. @@ -965,7 +967,7 @@ async def get_invites(self) -> AsyncGenerator[Invite, None]: Returns an async generator of invites for the guild. Requires the ``MANAGE_GUILD`` permission. - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.invite.Invite`, :data:`None`] An async generator of invites. @@ -979,7 +981,7 @@ async def get_integrations(self) -> AsyncGenerator[Integration, None]: Returns an async generator of integrations for the guild. Requires the ``MANAGE_GUILD`` permission. - Returns + Yields ------- AsyncGenerator[:class:`~pincer.objects.integration.Integration`, :data:`None`] An async generator of integrations. @@ -1162,7 +1164,7 @@ async def modify_welcome_screen( ) return WelcomeScreen.from_dict(construct_client_dict(self._client, data)) - async def modify_curent_user_voice_state( + async def modify_current_user_voice_state( self, channel_id: Snowflake, suppress: Optional[bool] = None, @@ -1236,6 +1238,300 @@ async def modify_user_voice_state( } ) + async def get_audit_log(self) -> AuditLog: + """|coro| + Returns an audit log object for the guild. + Requires the ``VIEW_AUDIT_LOG`` permission. + + Returns + ------- + :class:`~pincer.objects.guild.audit_log.AuditLog` + The audit log object for the guild. + """ + return AuditLog.from_dict( + construct_client_dict( + self._client, + await self._http.get(f"guilds/{self.id}/audit-logs") + ) + ) + + async def get_emojis(self) -> AsyncGenerator[Emoji, None]: + """|coro| + Returns an async generator of the emojis in the guild. + + Yields + ------ + :class:`~pincer.objects.guild.emoji.Emoji` + The emoji object. + """ + data = await self._http.get(f"guilds/{self.id}/emojis") + for emoji_data in data: + yield Emoji.from_dict( + construct_client_dict(self._client, emoji_data) + ) + + async def get_emoji(self, id: Snowflake) -> Emoji: + """|coro| + Returns an emoji object for the given ID. + + Parameters + ---------- + id : :class:`~pincer.utils.snowflake.Snowflake` + The ID of the emoji + + Returns + ------- + :class:`~pincer.objects.guild.emoji.Emoji` + The emoji object. + """ + return Emoji.from_dict( + construct_client_dict( + self._client, + await self._http.get(f"guilds/{self.id}/emojis/{id}") + ) + ) + + async def create_emoji( + self, + *, + name: str, + image: str, + roles: List[Snowflake], + reason: Optional[str] = None + ) -> Emoji: + """|coro| + Creates a new emoji for the guild. + Requires the ``MANAGE_EMOJIS_AND_STICKERS`` permission. + + Emojis and animated emojis have a maximum file size of 256kb. + Attempting to upload an emoji larger than this limit will fail. + + Parameters + ---------- + name : :class:`str` + Name of the emoji + image : :class:`str` + The 128x128 emoji image data + roles : List[:class:`~pincer.utils.snowflake.Snowflake`] + Roles allowed to use this emoji + reason : Optional[:class:`str`] + The reason for creating the emoji |default| :data:`None` + + Returns + ------- + :class:`~pincer.objects.guild.emoji.Emoji` + The newly created emoji object. + """ + data = await self._http.post( + f"guilds/{self.id}/emojis", + data={ + "name": name, + "image": image, + "roles": roles + }, + headers=remove_none({"X-Audit-Log-Reason": reason}) + ) + return Emoji.from_dict( + construct_client_dict(self._client, data) + ) + + async def edit_emoji( + self, + id: Snowflake, + *, + name: Optional[str] = None, + roles: Optional[List[Snowflake]] = None, + reason: Optional[str] = None + ) -> Emoji: + """|coro| + Modifies the given emoji. + Requires the ``MANAGE_EMOJIS_AND_STICKERS`` permission. + + Parameters + ---------- + id : :class:`~pincer.utils.snowflake.Snowflake` + The ID of the emoji + name : Optional[:class:`str`] + Name of the emoji |default| :data:`None` + roles : Optional[List[:class:`~pincer.utils.snowflake.Snowflake`]] + Roles allowed to use this emoji |default| :data:`None` + reason : Optional[:class:`str`] + The reason for editing the emoji |default| :data:`None` + + Returns + ------- + :class:`~pincer.objects.guild.emoji.Emoji` + The modified emoji object. + """ + data = await self._http.patch( + f"guilds/{self.id}/emojis/{id}", + data={ + "name": name, + "roles": roles + }, + headers=remove_none({"X-Audit-Log-Reason": reason}) + ) + return Emoji.from_dict( + construct_client_dict(self._client, data) + ) + + async def delete_emoji( + self, + id: Snowflake, + *, + reason: Optional[str] = None + ): + """|coro| + Deletes the given emoji. + Requires the ``MANAGE_EMOJIS_AND_STICKERS`` permission. + + Parameters + ---------- + id : :class:`~pincer.utils.snowflake.Snowflake` + The ID of the emoji + reason : Optional[:class:`str`] + The reason for deleting the emoji |default| :data:`None` + """ + await self._http.delete( + f"guilds/{self.id}/emojis/{id}", + headers=remove_none({"X-Audit-Log-Reason": reason}) + ) + + async def get_templates(self) -> AsyncGenerator[GuildTemplate, None]: + """|coro| + Returns an async generator of the guild templates. + + Yields + ------- + AsyncGenerator[:class:`~pincer.objects.guild.template.GuildTemplate`, :data:`None`] + The guild template object. + """ + data = await self._http.get(f"guilds/{self.id}/templates") + for template_data in data: + yield GuildTemplate.from_dict( + construct_client_dict(self._client, template_data) + ) + + async def create_template( + self, + name: str, + description: Optional[str] = None + ) -> GuildTemplate: + """|coro| + Creates a new template for the guild. + Requires the ``MANAGE_GUILD`` permission. + + Parameters + ---------- + name : :class:`str` + Name of the template (1-100 characters) + description : Optional[:class:`str`] + Description of the template + (0-120 characters) |default| :data:`None` + Returns + ------- + :class:`~pincer.objects.guild.template.GuildTemplate` + The newly created template object. + """ + data = await self._http.post( + f"guilds/{self.id}/templates", + data={ + "name": name, + "description": description + } + ) + return GuildTemplate.from_dict( + construct_client_dict(self._client, data) + ) + + async def sync_template( + self, + template: GuildTemplate + ) -> GuildTemplate: + """|coro| + Syncs the given template. + Requires the ``MANAGE_GUILD`` permission. + + Parameters + ---------- + template : :class:`~pincer.objects.guild.template.GuildTemplate` + The template to sync + + Returns + ------- + :class:`~pincer.objects.guild.template.GuildTemplate` + The synced template object. + """ + data = await self._http.put( + f"guilds/{self.id}/templates/{template.code}" + ) + return GuildTemplate.from_dict( + construct_client_dict(self._client, data) + ) + + async def edit_template( + self, + template: GuildTemplate, + *, + name: Optional[str] = None, + description: Optional[str] = None + ) -> GuildTemplate: + """|coro| + Modifies the template's metadata. + Requires the ``MANAGE_GUILD`` permission. + + Parameters + ---------- + template : :class:`~pincer.objects.guild.template.GuildTemplate` + The template to edit + name : Optional[:class:`str`] + Name of the template (1-100 characters) + |default| :data:`None` + description : Optional[:class:`str`] + Description of the template (0-120 characters) + |default| :data:`None` + + Returns + ------- + :class:`~pincer.objects.guild.template.GuildTemplate` + The edited template object. + """ + data = await self._http.patch( + f"guilds/{self.id}/templates/{template.code}", + data={ + "name": name, + "description": description + } + ) + return GuildTemplate.from_dict( + construct_client_dict(self._client, data) + ) + + async def delete_template( + self, + template: GuildTemplate + ) -> GuildTemplate: + """|coro| + Deletes the given template. + Requires the ``MANAGE_GUILD`` permission. + + Parameters + ---------- + template : :class:`~pincer.objects.guild.template.GuildTemplate` + The template to delete + + Returns + ------- + :class:`~pincer.objects.guild.template.GuildTemplate` + The deleted template object. + """ + data = await self._http.delete( + f"guilds/{self.id}/templates/{template.code}" + ) + return GuildTemplate.from_dict( + construct_client_dict(self._client, data) + ) + @classmethod def from_dict(cls, data) -> Guild: """ diff --git a/pincer/objects/guild/member.py b/pincer/objects/guild/member.py index 4e0bc314..1e67f0cd 100644 --- a/pincer/objects/guild/member.py +++ b/pincer/objects/guild/member.py @@ -36,10 +36,10 @@ class BaseMember(APIObject): hoisted_role: APINullable[:class:`~pincer.utils.snowflake.Snowflake`] The user's top role in the guild. """ - deaf: bool - joined_at: Timestamp - mute: bool - roles: List[Snowflake] + joined_at: APINullable[Timestamp] = MISSING + roles: APINullable[List[Snowflake]] = MISSING + deaf: bool = MISSING + mute: bool = MISSING hoisted_role: APINullable[Snowflake] = MISSING @@ -76,7 +76,7 @@ class PartialGuildMember(APIObject): @dataclass -class GuildMember(BaseMember, APIObject): +class GuildMember(BaseMember, User, APIObject): """Represents a member which resides in a guild/server. Attributes @@ -107,6 +107,28 @@ class GuildMember(BaseMember, APIObject): user: APINullable[User] = MISSING avatar: APINullable[str] = MISSING + def __post_init__(self): + super().__post_init__() + + if self.user is not MISSING: + self.set_user_data(self.user) + + def set_user_data(self, user: User): + """ + Used to set the user parameters of a GuildMember instance + + user: APINullable[:class:`~pincer.objects.user.user.User`] + A user class to copy the fields from + """ + + # Inspired from this thread + # https://stackoverflow.com/questions/57962873/easiest-way-to-copy-all-fields-from-one-dataclass-instance-to-another + + for key, value in user.__dict__.items(): + setattr(self, key, value) + + self.user = MISSING + @classmethod async def from_id( cls, diff --git a/pincer/objects/message/user_message.py b/pincer/objects/message/user_message.py index 8c4593cd..d75c24aa 100644 --- a/pincer/objects/message/user_message.py +++ b/pincer/objects/message/user_message.py @@ -226,6 +226,7 @@ class MessageType(IntEnum): THREAD_STARTER_MESSAGE = 21 GUILD_INVITE_REMINDER = 22 + CONTEXT_MENU_COMMAND = 23 @dataclass diff --git a/pincer/objects/user/user.py b/pincer/objects/user/user.py index 418c9291..9c252340 100644 --- a/pincer/objects/user/user.py +++ b/pincer/objects/user/user.py @@ -110,7 +110,7 @@ class User(APIObject): Whether the email on this account has been verified """ - id: Snowflake + id: APINullable[Snowflake] = MISSING username: APINullable[str] = MISSING discriminator: APINullable[str] = MISSING @@ -134,8 +134,9 @@ def premium(self) -> APINullable[PremiumTypes]: user their premium type in a usable enum. """ return ( - MISSING if self.premium_type is MISSING else PremiumTypes( - self.premium_type) + MISSING + if self.premium_type is MISSING + else PremiumTypes(self.premium_type) ) @property @@ -179,7 +180,9 @@ async def get_avatar(self, size: int = 512, ext: str = "png") -> Image: :class: Image The user's avatar as a Pillow image. """ - async with ClientSession().get(url=self.get_avatar_url()) as resp: + async with ClientSession().get( + url=self.get_avatar_url(size, ext) + ) as resp: avatar = io.BytesIO(await resp.read()) return Image.open(avatar).convert("RGBA") @@ -221,9 +224,8 @@ async def get_dm_channel(self) -> channel.Channel: construct_client_dict( self._client, await self._http.post( - "/users/@me/channels", - data={"recipient_id": self.id} - ) + "/users/@me/channels", data={"recipient_id": self.id} + ), ) ) @@ -236,5 +238,5 @@ async def send(self, message: MessageConvertable) -> UserMessage: message : :class:`~pincer.utils.convert_message.MessageConvertable` Message to be sent to the user. """ - channel = await self.get_dm_channel() - return await channel.send(message) + _channel = await self.get_dm_channel() + return await _channel.send(message) diff --git a/pincer/utils/__init__.py b/pincer/utils/__init__.py index 023b9a15..1735a1da 100644 --- a/pincer/utils/__init__.py +++ b/pincer/utils/__init__.py @@ -14,15 +14,14 @@ from .timestamp import Timestamp from .types import ( - APINullable, Coro, MISSING, Descripted, MissingType, Choices, - choice_value_types, CheckFunction + APINullable, Coro, MISSING, MissingType, choice_value_types, CheckFunction ) __all__ = ( - "APINullable", "APIObject", "CheckFunction", "Choices", "Color", - "Coro", "Descripted", "EventMgr", "HTTPMeta", "MISSING", "MissingType", - "Snowflake", "Task", "TaskScheduler", "Timestamp", "chdir", - "choice_value_types", "convert", "get_index", "get_params", - "get_signature_and_params", "should_pass_cls", "should_pass_ctx" + "APINullable", "APIObject", "CheckFunction", "Color", "Coro", + "EventMgr", "HTTPMeta", "MISSING", "MissingType", "Snowflake", "Task", + "TaskScheduler", "Timestamp", "chdir", "choice_value_types", "convert", + "get_index", "get_params", "get_signature_and_params", "should_pass_cls", + "should_pass_ctx" ) diff --git a/pincer/utils/api_object.py b/pincer/utils/api_object.py index 79bea7b8..aca6b618 100644 --- a/pincer/utils/api_object.py +++ b/pincer/utils/api_object.py @@ -210,7 +210,11 @@ def __post_init__(self): self.__attr_convert(attr_item, classes[0]) for attr_item in attr_gotten ] - + elif tp == dict and attr_gotten and (classes := get_args(types[0])): + attr_value = { + key: self.__attr_convert(value, classes[1]) + for key, value in attr_gotten.items() + } else: attr_value = self.__attr_convert(attr_gotten, specific_tp) @@ -229,10 +233,7 @@ def __str__(self): ------- """ - if self.__dict__.get('name'): - return self.name - - return super().__str__() + return getattr(self, 'id', None) or super().__str__() @classmethod def from_dict( diff --git a/pincer/utils/conversion.py b/pincer/utils/conversion.py index 7332c50a..741d20b9 100644 --- a/pincer/utils/conversion.py +++ b/pincer/utils/conversion.py @@ -81,4 +81,4 @@ def remove_none(obj: Union[List, Dict, Set]) -> Union[List, Dict, Set]: elif isinstance(obj, set): return obj - {None} elif isinstance(obj, dict): - return {k: v for k, v in obj.items() if None not in {k, v}} \ No newline at end of file + return {k: v for k, v in obj.items() if None not in {k, v}} diff --git a/pincer/utils/event_mgr.py b/pincer/utils/event_mgr.py index 759c5f88..2defb81a 100644 --- a/pincer/utils/event_mgr.py +++ b/pincer/utils/event_mgr.py @@ -18,7 +18,7 @@ class _Processable(ABC): @abstractmethod - def process(self, event_name: str, *args): + def process(self, event_name: str, event_value: Any): """ Method that is ran when an event is received from discord. @@ -26,8 +26,8 @@ def process(self, event_name: str, *args): ---------- event_name : str The name of the event. - *args : Any - Arguments to evaluate check with. + event_value : Any + Object to evaluate check with. Returns ------- @@ -35,20 +35,24 @@ def process(self, event_name: str, *args): Whether the event can be set """ - def matches_event(self, event_name: str, *args): + def matches_event(self, event_name: str, event_value: Any): """ Parameters ---------- event_name : str Name of event. - *args : Any - Arguments to eval check with. + event_value : Any + Object to eval check with. """ if self.event_name != event_name: return False if self.check: - return self.check(*args) + if event_value is not None: + return self.check(event_value) + else: + # Certain middleware do not have an event_value + return self.check() return True @@ -100,7 +104,7 @@ async def wait(self): """Waits until ``self.event`` is set.""" await self.event.wait() - def process(self, event_name: str, *args) -> bool: + def process(self, event_name: str, event_value: Any) -> bool: # TODO: fix docs """ @@ -113,8 +117,8 @@ def process(self, event_name: str, *args) -> bool: ------- """ - if self.matches_event(event_name, *args): - self.return_value = args + if self.matches_event(event_name, event_value): + self.return_value = event_value self.event.set() @@ -151,7 +155,7 @@ def __init__(self, event_name: str, check: CheckFunction) -> None: self.events = deque() self.wait = Event() - def process(self, event_name: str, *args): + def process(self, event_name: str, event_value: Any): # TODO: fix docs """ @@ -167,8 +171,8 @@ def process(self, event_name: str, *args): if not self.can_expand: return - if self.matches_event(event_name, *args): - self.events.append(args) + if self.matches_event(event_name, event_value): + self.events.append(event_value) self.wait.set() async def get_next(self): @@ -196,17 +200,17 @@ class EventMgr: def __init__(self): self.event_list: List[_Processable] = [] - def process_events(self, event_name, *args): + def process_events(self, event_name, event_value): """ Parameters ---------- event_name : str The name of the event to be processed. - *args : Any - The arguments returned from the middleware for this event. + event_value : Any + The object returned from the middleware for this event. """ for event in self.event_list: - event.process(event_name, *args) + event.process(event_name, event_value) async def wait_for( self, diff --git a/pincer/utils/types.py b/pincer/utils/types.py index c1cc426b..48f2e97c 100644 --- a/pincer/utils/types.py +++ b/pincer/utils/types.py @@ -4,14 +4,9 @@ from sys import modules from typing import ( - TYPE_CHECKING, TypeVar, Callable, Coroutine, Any, Union, Literal, Optional + TYPE_CHECKING, TypeVar, Callable, Coroutine, Any, Union, Optional ) -from pincer.exceptions import InvalidArgumentAnnotation - -if TYPE_CHECKING: - from typing import Tuple - class MissingType: """Type class for missing attributes and parameters.""" @@ -38,11 +33,7 @@ def __bool__(self) -> bool: #: Represents a coroutine. Coro = TypeVar("Coro", bound=Callable[..., Coroutine[Any, Any, Any]]) - -Choices = Literal - - -choice_value_types = (str, int, float) +choice_value_types = Union[str, int, float] CheckFunction = Optional[Callable[[Any], bool]] @@ -73,29 +64,3 @@ def __init__(self): continue TypeCache.cache.update(lcp[module].__dict__) - - -class _TypeInstanceMeta(type): - def __getitem__(cls, args: Tuple[T, str]): - if not isinstance(args, tuple) or len(args) != 2: - raise InvalidArgumentAnnotation( - "Descripted arguments must be a tuple of length 2. " - "(if you are using this as the indented type, just " - "pass two arguments)" - ) - - return cls(*args) - - -class Descripted(metaclass=_TypeInstanceMeta): - # TODO: Write example & more docs - """Description type.""" - - def __init__(self, key: Any, description: str): - if not isinstance(description, str): - raise RuntimeError( - "The description value must always be a string!" - ) - - self.key = key - self.description = description diff --git a/setup.cfg b/setup.cfg index 38d37b54..fa32a9b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pincer -version = 0.12.1 +version = 0.13.0 description = Discord API wrapper rebuild from scratch. long_description = file: docs/PYPI.md long_description_content_type = text/markdown @@ -33,6 +33,7 @@ classifiers = include_package_data = True packages = pincer + pincer.commands pincer.objects pincer.objects.events pincer.objects.app @@ -50,7 +51,7 @@ python_requires = >=3.8 [options.extras_require] testing = - coverage==6.1.2 + coverage==6.2 flake8==4.0.1 tox==3.24.4 pre-commit==2.15.0