From 3cfe5733e5e02b689d967a7b3625dc862445aee9 Mon Sep 17 00:00:00 2001 From: Jed Hazaymeh Date: Thu, 1 Feb 2024 21:58:59 +0000 Subject: [PATCH] Refactor codebase & README (#37) --- README.md | 74 ++++++++++++++++++------------- src/__main__.py | 5 +-- src/bot.py | 16 ++++++- src/config.py | 6 ++- src/extensions/boosts.py | 66 +++++++++++++++------------- src/extensions/hello_world.py | 25 ++++++----- src/extensions/user_roles.py | 83 +++++++++++++++++++++++++++++++++++ src/extensions/userroles.py | 77 -------------------------------- src/utils.py | 8 ++++ 9 files changed, 205 insertions(+), 155 deletions(-) create mode 100644 src/extensions/user_roles.py delete mode 100644 src/extensions/userroles.py create mode 100644 src/utils.py diff --git a/README.md b/README.md index 38dc99f..d848596 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,53 @@ # Blockbot -This Discord bot uses the `interactions.py` module to interface with the Discord API. +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. -For help with using the module, check out the [documentation](https://interactions-py.github.io/interactions.py/Guides/). +## Resources -## Overview +- [`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) -> `main.py:` -- A custom, dynamic extension (command) loader. Write an extension in the `extensions/` directory, and it will automatically be loaded when the bot boots. +## File Structure -> `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. - -> `config.py:` -- This module houses the bot configuration. The values are loaded from environment variables, so you can set them in your shell or in a `.env` file. +- `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 -> 1. Clone this repository. To switch to a different version, `cd` into this cloned repository and run `git checkout [branch name/version here]` -> 2. It's generally advised to work in a Python virtual environment. Here are steps to create one *(the `discord-py-interactions` library requires Python 3.10.0 or later)*: -> > - `$` `python3 -m venv env` -> > - `$` `source env/bin/activate` -> 3. Create a Discord bot token from [here](https://discord.com/developers/applications/) -> **Register it for slash commands:** -> - Under *OAuth2 > General*, set the Authorization Method to "In-app Authorization" -> - Tick `bot` and `applications.commands` -> - Go to *OAuth2 > URL Generator*, tick `bot` and `applications.commands` -> - Copy the generated URL at the bottom of the page to invite it to desired servers -> 4. Make a new file called `.env` inside the repo folder and paste the below code block in the file -> ``` -> TOKEN= -> ``` -> 5. Run `pip install -r requirements.txt` to install packages. You'll need Python 3.10 or later -> 6. Once that's done, run the bot by executing `python3 main.py` in the terminal - -*If you aren't sure how to obtain your server ID, check out [this article](https://www.alphr.com/discord-find-server-id/)* - -*If you get errors related to missing token environment variables, run `source .env`* +### 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/src/__main__.py b/src/__main__.py index 925d36c..4db49ff 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,7 +1,6 @@ -"""Entrypoint script to load extensions and start the client.""" -import hikari +from hikari import Activity, ActivityType from src.bot import bot if __name__ == "__main__": - bot.run(activity=hikari.Activity(name="Webgroup issues", type=hikari.ActivityType.WATCHING)) + bot.run(activity=Activity(name="Webgroup issues", type=ActivityType.WATCHING)) diff --git a/src/bot.py b/src/bot.py index e0eefef..84cfbca 100644 --- a/src/bot.py +++ b/src/bot.py @@ -19,5 +19,17 @@ logging.info(f"Debug mode is {DEBUG}; You can safely ignore this.") -arc_client = arc.GatewayClient(bot, is_dm_enabled=False) -arc_client.load_extensions_from("./src/extensions/") +client = arc.GatewayClient(bot, is_dm_enabled=False) +client.load_extensions_from("./src/extensions/") + +@client.set_error_handler +async def error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: + if DEBUG: + message = f"```{exc}```" + else: + message = "If this persists, create an issue at ." + + await ctx.respond(f"❌ Blockbot encountered an unhandled exception. {message}") + logging.error(exc) + + raise exc diff --git a/src/config.py b/src/config.py index 64b3266..e2ed684 100644 --- a/src/config.py +++ b/src/config.py @@ -4,7 +4,9 @@ load_dotenv() -TOKEN = os.environ.get("TOKEN") # required +TOKEN = os.environ.get("TOKEN") # required DEBUG = os.environ.get("DEBUG", False) -CHANNEL_IDS = {"lobby": 627542044390457350} +CHANNEL_IDS = { + "lobby": "627542044390457350" +} diff --git a/src/extensions/boosts.py b/src/extensions/boosts.py index 4ebf266..669a454 100644 --- a/src/extensions/boosts.py +++ b/src/extensions/boosts.py @@ -2,47 +2,53 @@ import hikari from src.config import CHANNEL_IDS +from src.utils import get_guild plugin = arc.GatewayPlugin(name="Boosts") -TIER_COUNT: dict[hikari.MessageType, None | int] = { - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION: None, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1: 1, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2: 2, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3: 3, -} - - -# NOTE: this is baked into discord-interactions-py, so I extracted and cleaned up the logic -def get_boost_message( - message_type: hikari.MessageType | int, content: str | None, author: hikari.Member, guild: hikari.Guild +BOOST_TIERS: list[hikari.MessageType] = [ + hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1, + hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2, + hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3, +] + +BOOST_MESSAGE_TYPES: list[hikari.MessageType] = BOOST_TIERS + [ + hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION, +] + +def build_boost_message( + message_type: hikari.MessageType | int, + number_of_boosts: str | None, + booster_user: hikari.Member, + guild: hikari.Guild ) -> str: - assert message_type in ( - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2, - hikari.MessageType.USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3, - ) + assert message_type in BOOST_MESSAGE_TYPES - message = f"{author.display_name} just boosted the server{f' **{content}** times' if content else ''}!" + base_message = f"{booster_user.display_name} just boosted the server" + multiple_boosts_message = f" **{number_of_boosts}** times" if number_of_boosts else "" - if (count := TIER_COUNT[message_type]) is not None: - message += f"{guild.name} has achieved **Level {count}!**" + message = base_message + multiple_boosts_message + "!" - return message + if (message_type in BOOST_TIERS): + count = BOOST_TIERS.index(message_type) + 1 + message += f"\n{guild.name} has reached **Level {count}!**" + return message @plugin.listen() async def on_message(event: hikari.GuildMessageCreateEvent): - if event.message.type in TIER_COUNT: - assert event.member is not None - message = get_boost_message( - event.message.type, - event.content, - event.member, - event.get_guild() or await plugin.client.rest.fetch_guild(event.guild_id), - ) - await plugin.client.rest.create_message(CHANNEL_IDS["lobby"], content=message) + if not event.message.type in BOOST_MESSAGE_TYPES: + return + + assert event.member is not None + message = build_boost_message( + event.message.type, + number_of_boosts=event.content, + booster_user=event.member, + guild=await get_guild(plugin.client, event) + ) + + await plugin.client.rest.create_message(CHANNEL_IDS["lobby"], content=message) @arc.loader diff --git a/src/extensions/hello_world.py b/src/extensions/hello_world.py index 655e1d4..df2891f 100644 --- a/src/extensions/hello_world.py +++ b/src/extensions/hello_world.py @@ -4,8 +4,7 @@ import arc import hikari -plugin = arc.GatewayPlugin(name="hello_world") - +plugin = arc.GatewayPlugin(name="Hello World") @plugin.include @arc.slash_command("hello", "Say hello!") @@ -13,17 +12,14 @@ 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( @@ -33,19 +29,27 @@ async def options( 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 = 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) + 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) @@ -55,7 +59,6 @@ async def components(ctx: arc.GatewayContext) -> None: await ctx.respond("Here are some components", components=[builder, button]) - @plugin.listen() async def on_interaction(event: hikari.InteractionCreateEvent) -> None: interaction = event.interaction @@ -67,7 +70,8 @@ async def on_interaction(event: hikari.InteractionCreateEvent) -> None: if interaction.custom_id == "click_me": await interaction.create_initial_response( - hikari.ResponseType.MESSAGE_CREATE, f"{interaction.user.mention}, you clicked me!" + hikari.ResponseType.MESSAGE_CREATE, + f"{interaction.user.mention}, you clicked me!" ) elif interaction.custom_id == "select_me": await interaction.create_initial_response( @@ -75,7 +79,6 @@ async def on_interaction(event: hikari.InteractionCreateEvent) -> None: f"{interaction.user.mention}, you selected {' '.join(interaction.values)}", ) - @arc.loader def loader(client: arc.GatewayClient) -> None: client.add_plugin(plugin) diff --git a/src/extensions/user_roles.py b/src/extensions/user_roles.py new file mode 100644 index 0000000..e8ba8a1 --- /dev/null +++ b/src/extensions/user_roles.py @@ -0,0 +1,83 @@ +import arc +import hikari + +from src.utils import role_mention + +plugin = arc.GatewayPlugin(name="User Roles") + +role = plugin.include_slash_group("role", "Get/remove assignable roles.") + +role_choices = [ + hikari.CommandChoice(name="Webgroup", value="1166751688598761583"), + hikari.CommandChoice(name="Gamez", value="1089204642241581139"), + hikari.CommandChoice(name="Croomer", value="1172696659097047050"), +] + +@role.include +@arc.slash_subcommand("add", "Add an assignable role.") +async def add_role( + ctx: arc.GatewayContext, + role: arc.Option[str, arc.StrParams("The role to add.", choices=role_choices)] +) -> None: + assert ctx.guild_id + assert ctx.member + + if int(role) in ctx.member.role_ids: + return await ctx.respond( + f"You already have the {role_mention(role)} role.", + flags=hikari.MessageFlag.EPHEMERAL + ) + + await ctx.client.rest.add_role_to_member( + ctx.guild_id, ctx.author, int(role), reason="Self-service role." + ) + await ctx.respond( + f"Done! Added {role_mention(role)} to your roles.", + flags=hikari.MessageFlag.EPHEMERAL + ) + +@role.include +@arc.slash_subcommand("remove", "Remove an assignable role.") +async def remove_role( + ctx: arc.GatewayContext, + role: arc.Option[str, arc.StrParams("The role to remove.", choices=role_choices)] +) -> None: + assert ctx.guild_id + assert ctx.member + + if int(role) not in ctx.member.role_ids: + return await ctx.respond( + f"You don't have the {role_mention(role)} role.", + flags=hikari.MessageFlag.EPHEMERAL + ) + + await ctx.client.rest.remove_role_from_member( + ctx.guild_id, ctx.author, int(role), reason=f"{ctx.author} removed role." + ) + await ctx.respond( + f"Done! Removed {role_mention(role)} from your roles.", + flags=hikari.MessageFlag.EPHEMERAL + ) + +@role.set_error_handler +async def role_error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: + role = ctx.get_option("role", arc.OptionType.STRING) + assert role is not None + + if isinstance(exc, hikari.ForbiddenError): + return await ctx.respond( + f"❌ Blockbot is not permitted to self-service the {role_mention(role)} role.", + flags=hikari.MessageFlag.EPHEMERAL + ) + + if isinstance(exc, hikari.NotFoundError): + return await ctx.respond( + f"❌ Blockbot can't find that role.", + flags=hikari.MessageFlag.EPHEMERAL + ) + + raise exc + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(plugin) diff --git a/src/extensions/userroles.py b/src/extensions/userroles.py deleted file mode 100644 index 563938d..0000000 --- a/src/extensions/userroles.py +++ /dev/null @@ -1,77 +0,0 @@ -import arc -import hikari - -plugin = arc.GatewayPlugin("User Roles") - -role = plugin.include_slash_group("role", "Get/remove assignable roles.") - -role_choices = [ - hikari.CommandChoice(name="Webgroup", value="1166751688598761583"), - hikari.CommandChoice(name="Gamez", value="1089204642241581139"), - hikari.CommandChoice(name="Croomer", value="1172696659097047050"), -] - - -@role.include -@arc.slash_subcommand("add", "Add an assignable role.") -async def add_role( - ctx: arc.GatewayContext, role: arc.Option[str, arc.StrParams("The role to add.", choices=role_choices)] -) -> None: - assert ctx.guild_id - assert ctx.member - - role_id = int(role) - if role_id not in ctx.member.role_ids: - await ctx.client.rest.add_role_to_member( - ctx.guild_id, ctx.author, int(role), reason=f"{ctx.author} added role." - ) - await ctx.respond(f"Done! Added <@&{role}> to your roles.", flags=hikari.MessageFlag.EPHEMERAL) - return - - await ctx.respond(f"You already have <@&{role}>!", flags=hikari.MessageFlag.EPHEMERAL) - - -@role.include -@arc.slash_subcommand("remove", "Remove an assignable role.") -async def remove_role( - ctx: arc.GatewayContext, role: arc.Option[str, arc.StrParams("The role to remove.", choices=role_choices)] -) -> None: - assert ctx.guild_id - assert ctx.member - - role_id = int(role) - if role_id in ctx.member.role_ids: - await ctx.client.rest.remove_role_from_member( - ctx.guild_id, ctx.author, int(role), reason=f"{ctx.author} removed role." - ) - await ctx.respond(f"Done! Removed <@&{role}> from your roles.", flags=hikari.MessageFlag.EPHEMERAL) - return - - await ctx.respond(f"You don't have <@&{role}>!", flags=hikari.MessageFlag.EPHEMERAL) - - -@add_role.set_error_handler -async def add_error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: - await role_error_handler(ctx, exc, "obtain") - - -@remove_role.set_error_handler -async def remove_error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: - await role_error_handler(ctx, exc, "remove") - - -async def role_error_handler(ctx: arc.GatewayContext, exc: Exception, type: str) -> None: - role = ctx.get_option("role", arc.OptionType.STRING) - assert role is not None - role_id = int(role) - - if isinstance(exc, hikari.ForbiddenError): - await ctx.respond(f"You don't have permission to {type} <@&{role_id}>.", flags=hikari.MessageFlag.EPHEMERAL) - return - - raise exc - - -@arc.loader -def loader(client: arc.GatewayClient) -> None: - client.add_plugin(plugin) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..db38338 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,8 @@ +import hikari +from arc import GatewayClient + +async def get_guild(client: GatewayClient, event: hikari.GuildMessageCreateEvent): + return event.get_guild() or await client.rest.fetch_guild(event.guild_id) + +def role_mention(role_id: hikari.Snowflake | int | str): + return f"<@&{role_id}>"