From 65d1d955f69fcdb49f58d9529f0e61956d24971f Mon Sep 17 00:00:00 2001 From: nova <110734810+novanai@users.noreply.github.com> Date: Tue, 12 Nov 2024 11:24:27 +0000 Subject: [PATCH] Update README & refactor examples (#44) Co-authored-by: Ayden --- .env.sample | 1 + README.md | 51 ++------------------ requirements.txt | 7 ++- requirements_dev.txt | 2 + src/bot.py | 8 +++ src/examples/commands.py | 35 ++++++++++++++ src/examples/components.py | 67 ++++++++++++++++++++++++++ src/examples/modals.py | 45 +++++++++++++++++ src/examples/options.py | 32 ++++++++++++ src/extensions/hello_world.py | 91 ----------------------------------- 10 files changed, 196 insertions(+), 143 deletions(-) create mode 100644 .env.sample create mode 100644 requirements_dev.txt create mode 100644 src/examples/commands.py create mode 100644 src/examples/components.py create mode 100644 src/examples/modals.py create mode 100644 src/examples/options.py delete mode 100644 src/extensions/hello_world.py diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..f656e0f --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +TOKEN= diff --git a/README.md b/README.md index d848596..f755d58 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,8 @@ # Blockbot -Blockbot is a Discord bot, written in Python, that is maintained by the Redbrick Webgroup. This project uses [`hikari`](https://github.com/hikari-py/hikari/), an opinionated microframework, to interface with the Discord API. [`hikari-arc`](https://github.com/hypergonial/hikari-arc) is the command handler of choice. +Blockbot is a Discord bot, written in Python, that is maintained by the [Redbrick Webgroup](https://docs.redbrick.dcu.ie/webgroup/). -## Resources +## Documentation -- [`hikari` Documentation](https://docs.hikari-py.dev/en/latest/) -- [`hikari-arc` Documentation](https://arc.hypergonial.com/) -- [Examples](https://github.com/hypergonial/hikari-arc/tree/main/examples/gateway) +Development documentation is hosted on the [Redbrick docs](https://docs.redbrick.dcu.ie/webgroup/blockbot/). -## File Structure - -- `bot.py` - - This is the file that contains the bot configuration and instantiation, while also being responsible for loading the bot extensions. -- `extensions/` - - This directory is home to the custom extensions (known as cogs in `discord.py`, or command groups) that are loaded when the bot is started. Extensions are classes that encapsulate command logic and can be dynamically loaded/unloaded. In `hikari-arc`, an intuitive [plugin system](https://arc.hypergonial.com/guides/plugins_extensions/) is used. -- `config.py` - - Configuration secrets and important constants (such as identifiers) are stored here. The secrets are loaded from environment variables, so you can set them in your shell or in a `.env` file. -- `utils.py` - - Simple utility functions are stored here, that can be reused across the codebase. - -## Installation - -### Discord Developer Portal - -As a prerequisite, you need to have a bot application registered on the Discord developer portal. - -1. Create a Discord bot application [here](https://discord.com/developers/applications/). -2. When you have a bot application, register it for slash commands: -3. Go to *"OAuth2 > URL Generator"* on the left sidebar, select the `bot` and `applications.commands` scopes, scroll down & select the bot permissions you need (for development, you can select `Administator`). -4. Copy and visit the generated URL at the bottom of the page to invite it to the desired server. - -#### Bot Token - -- Go to the Discord developer portal and under *"Bot"* on the left sidebar, click `Reset Token`. Copy the generated token. - -### Source Code -1. `git clone` and `cd` into this repository. -2. It's generally advised to work in a Python [virtual environment](https://docs.python.org/3/library/venv.html): -```sh -python3 -m venv .venv -source .venv/bin/activate -``` -3. Create a new file called `.env` inside the repo folder and paste your bot token into the file as such: -``` -TOKEN= -``` -4. Run `pip install -r requirements.txt` to install the required packages. -5. Once that's done, start the bot by running `python3 -m src`. - -## FAQ - -- If you get errors related to missing token environment variables, run `source .env`. diff --git a/requirements.txt b/requirements.txt index 9f9d34c..35a4d0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ -hikari==2.0.0.dev122 -hikari-arc==1.1.0 -ruff==0.2.0 -pre-commit==3.6.0 +hikari==2.1.0 +hikari-arc==1.3.4 +hikari-miru==4.1.1 python-dotenv==1.0.1 pyfiglet==1.0.2 fortune-python==1.1.1 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..0d45ea9 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +ruff==0.6.9 +pre-commit==4.0.0 diff --git a/src/bot.py b/src/bot.py index 1f8a241..cd7be3c 100644 --- a/src/bot.py +++ b/src/bot.py @@ -3,6 +3,7 @@ import arc import hikari +import miru from src.config import DEBUG, TOKEN @@ -20,8 +21,15 @@ logging.info(f"Debug mode is {DEBUG}; You can safely ignore this.") client = arc.GatewayClient(bot, is_dm_enabled=False) +miru_client = miru.Client.from_arc(client) + +client.set_type_dependency(miru.Client, miru_client) + client.load_extensions_from("./src/extensions/") +if DEBUG: + client.load_extensions_from("./src/examples/") + @client.set_error_handler async def error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: diff --git a/src/examples/commands.py b/src/examples/commands.py new file mode 100644 index 0000000..546f6a5 --- /dev/null +++ b/src/examples/commands.py @@ -0,0 +1,35 @@ +import arc + +plugin = arc.GatewayPlugin(name="Example Commands") + + +@plugin.include +@arc.slash_command("hello", "Say hello!") +async def hello(ctx: arc.GatewayContext) -> None: + """An individual command, invoked by `/hello`.""" + await ctx.respond("Hello from hikari and hikari-arc!") + + +group = plugin.include_slash_group("base_group", "A base command group, with sub groups and sub commands.") + + +@group.include +@arc.slash_subcommand("sub_command", "A sub command") +async def sub_command(ctx: arc.GatewayContext) -> None: + """A subcommand, invoked by `/base_command sub_command`.""" + await ctx.respond("Hello, world! This is a sub command") + + +sub_group = group.include_subgroup("sub_group", "A subgroup to add commands to.") + + +@sub_group.include +@arc.slash_subcommand("sub_command", "A subgroup subcommand.") +async def sub_group_sub_command(ctx: arc.GatewayContext) -> None: + """A subcommand belonging to a subgroup, invoked by `/base_group sub_group sub_command`.""" + await ctx.respond("This is a subgroup subcommand.") + + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(plugin) diff --git a/src/examples/components.py b/src/examples/components.py new file mode 100644 index 0000000..c4781e9 --- /dev/null +++ b/src/examples/components.py @@ -0,0 +1,67 @@ +import arc +import hikari +import miru + +plugin = arc.GatewayPlugin("Example Components") + + +class View(miru.View): + def __init__(self, user_id: int) -> None: + self.user_id = user_id + + super().__init__(timeout=60) + + @miru.button("Click me!", custom_id="click_me") + async def click_button(self, ctx: miru.ViewContext, button: miru.Button) -> None: + await ctx.respond(f"{ctx.user.mention}, you clicked me!") + + # Defining select menus: https://miru.hypergonial.com/guides/selects/ + @miru.text_select( + custom_id="select_me", + placeholder="Choose your favourite colours...", + min_values=1, + max_values=3, + options=[ + miru.SelectOption(label=colour) + for colour in ["Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet"] + ], + ) + async def colour_select(self, ctx: miru.ViewContext, select: miru.TextSelect) -> None: + await ctx.respond(f"Your favourite colours are: {', '.join(select.values)}!") + + # Defining a custom view check: https://miru.hypergonial.com/guides/checks_timeout/#checks + async def view_check(self, ctx: miru.ViewContext) -> bool: + # This view will only handle interactions that belong to the + # user who originally ran the command. + # For every other user they will receive an error message. + if ctx.user.id != self.user_id: + await ctx.respond("You can't press this!", flags=hikari.MessageFlag.EPHEMERAL) + return False + + return True + + # Handling view timeouts: https://miru.hypergonial.com/guides/checks_timeout/#timeout + # Editing view items: https://miru.hypergonial.com/guides/editing_items/ + async def on_timeout(self) -> None: + message = self.message + assert message # Since the view is bound to a message, we can assert it's not None + + for item in self.children: + item.disabled = True + + await message.edit(components=self) + self.stop() + + +@plugin.include +@arc.slash_command("components", "A command with components.") +async def components_cmd(ctx: arc.GatewayContext, miru_client: miru.Client = arc.inject()) -> None: + view = View(ctx.user.id) + response = await ctx.respond("Here are some components...", components=view) + + miru_client.start_view(view, bind_to=await response.retrieve_message()) + + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(plugin) diff --git a/src/examples/modals.py b/src/examples/modals.py new file mode 100644 index 0000000..80c02ce --- /dev/null +++ b/src/examples/modals.py @@ -0,0 +1,45 @@ +import arc +import hikari +import miru + +plugin = arc.GatewayPlugin("Example Modal") + + +# Modals Guide: https://miru.hypergonial.com/guides/modals/ +class MyModal(miru.Modal, title="Tell us about yourself!"): + name = miru.TextInput( + label="Name", + placeholder="Enter your name!", + required=True, + ) + + bio = miru.TextInput( + label="Biography", + value="Age: \nHobbies:", # pre-filled content + style=hikari.TextInputStyle.PARAGRAPH, + required=False, + ) + + # The callback function is called after the user hits 'Submit' + async def callback(self, ctx: miru.ModalContext) -> None: + # values can also be accessed using ctx.values, + # Modal.values, or with ctx.get_value_by_id() + embed = hikari.Embed(title=self.name.value, description=self.bio.value) + await ctx.respond(embed=embed) + + +@plugin.include +@arc.slash_command("modal", "A command with a modal response.") +async def modal_command(ctx: arc.GatewayContext, miru_client: miru.Client = arc.inject()) -> None: + modal = MyModal() + builder = modal.build_response(miru_client) + + # arc has a built-in way to respond with a builder + await ctx.respond_with_builder(builder) + + miru_client.start_modal(modal) + + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(plugin) diff --git a/src/examples/options.py b/src/examples/options.py new file mode 100644 index 0000000..c1ac576 --- /dev/null +++ b/src/examples/options.py @@ -0,0 +1,32 @@ +import arc +import hikari + +plugin = arc.GatewayPlugin("Example Options") + + +# Options Guide: https://arc.hypergonial.com/guides/options/ +@plugin.include +@arc.slash_command("options", "A command with options") +async def options( + ctx: arc.GatewayContext, + str_option: arc.Option[str, arc.StrParams("A string option.", name="string")], + int_option: arc.Option[int, arc.IntParams("An integer option.", name="integer", min=5, max=150)], + attachment_option: arc.Option[hikari.Attachment, arc.AttachmentParams("An attachment option.", name="attachment")], + channel_option: arc.Option[ + hikari.TextableChannel | None, arc.ChannelParams("A textable channel option.", name="channel") + ] = None, +) -> None: + """A command with lots of options.""" + embed = hikari.Embed(title="There are a lot of options here", description="Maybe too many...", colour=0x5865F2) + embed.set_image(attachment_option) + + embed.add_field("String option", str_option, inline=True) + embed.add_field("Integer option", str(int_option), inline=True) + embed.add_field("Channel option", f"<#{channel_option.id}>" if channel_option else "Not supplied", inline=True) + + await ctx.respond(embed=embed) + + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(plugin) diff --git a/src/extensions/hello_world.py b/src/extensions/hello_world.py deleted file mode 100644 index 0de108a..0000000 --- a/src/extensions/hello_world.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Example extension with simple commands -""" - -import arc -import hikari - -plugin = arc.GatewayPlugin(name="Hello World") - - -@plugin.include -@arc.slash_command("hello", "Say hello!") -async def hello(ctx: arc.GatewayContext) -> None: - """A simple hello world command""" - await ctx.respond("Hello from hikari!") - - -group = plugin.include_slash_group("base_command", "A base command, to expand on") - - -@group.include -@arc.slash_subcommand("sub_command", "A sub command, to expand on") -async def sub_command(ctx: arc.GatewayContext) -> None: - """A simple sub command""" - await ctx.respond("Hello, world! This is a sub command") - - -@plugin.include -@arc.slash_command("options", "A command with options") -async def options( - ctx: arc.GatewayContext, - option_str: arc.Option[str, arc.StrParams("A string option")], - option_int: arc.Option[int, arc.IntParams("An integer option")], - option_attachment: arc.Option[ - hikari.Attachment, arc.AttachmentParams("An attachment option") - ], -) -> None: - """A command with lots of options""" - embed = hikari.Embed( - title="There are a lot of options here", - description="Maybe too many", - colour=0x5865F2, - ) - embed.set_image(option_attachment) - embed.add_field("String option", option_str, inline=False) - embed.add_field("Integer option", str(option_int), inline=False) - await ctx.respond(embed=embed) - - -@plugin.include -@arc.slash_command("components", "A command with components") -async def components(ctx: arc.GatewayContext) -> None: - """A command with components""" - builder = ctx.client.rest.build_message_action_row() - select_menu = builder.add_text_menu( - "select_me", placeholder="I wonder what this does", min_values=1, max_values=2 - ) - for opt in ("Select me!", "No, select me!", "Select me too!"): - select_menu.add_option(opt, opt) - - button = ctx.client.rest.build_message_action_row().add_interactive_button( - hikari.ButtonStyle.PRIMARY, "click_me", label="Click me!" - ) - - await ctx.respond("Here are some components", components=[builder, button]) - - -@plugin.listen() -async def on_interaction(event: hikari.InteractionCreateEvent) -> None: - interaction = event.interaction - - # Discussions are underway for allowing to listen for a "ComponentInteractionEvent" directly - # instead of doing this manual filtering: https://github.com/hikari-py/hikari/issues/1777 - if not isinstance(interaction, hikari.ComponentInteraction): - return - - if interaction.custom_id == "click_me": - await interaction.create_initial_response( - hikari.ResponseType.MESSAGE_CREATE, - f"{interaction.user.mention}, you clicked me!", - ) - elif interaction.custom_id == "select_me": - await interaction.create_initial_response( - hikari.ResponseType.MESSAGE_CREATE, - f"{interaction.user.mention}, you selected {' '.join(interaction.values)}", - ) - - -@arc.loader -def loader(client: arc.GatewayClient) -> None: - client.add_plugin(plugin)