From cce84f538441b720779f72449af3189531bf1849 Mon Sep 17 00:00:00 2001 From: nova <110734810+novanai@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:30:48 +0000 Subject: [PATCH] Hikari rewrite (#25) Co-authored-by: wizzdom Co-authored-by: XOREAX <57073484+koyakonsta@users.noreply.github.com> --- Dockerfile | 2 +- requirements.txt | 8 +- src/__main__.py | 7 ++ src/bot.py | 23 +++++ src/config.py | 17 ++-- src/extensions/boosts.py | 50 ++++++++++ src/extensions/hello_world.py | 183 +++++++++++++++------------------- src/extensions/userroles.py | 77 ++++++++++++++ src/main.py | 56 ----------- 9 files changed, 252 insertions(+), 171 deletions(-) create mode 100644 src/__main__.py create mode 100644 src/bot.py create mode 100644 src/extensions/boosts.py create mode 100644 src/extensions/userroles.py delete mode 100644 src/main.py diff --git a/Dockerfile b/Dockerfile index a6ba5f3..10415ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["python3", "src/main.py"] \ No newline at end of file +CMD ["python3", "-m", "src"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1f55c83..d6e51dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -discord-typings==0.5.1 -jurigged==0.5.6 -discord-py-interactions==5.0.0 -python-dotenv==0.19.1 -loguru==0.7.2 \ No newline at end of file +hikari==2.0.0.dev122 +hikari-arc==1.1.0 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..925d36c --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,7 @@ +"""Entrypoint script to load extensions and start the client.""" +import hikari + +from src.bot import bot + +if __name__ == "__main__": + bot.run(activity=hikari.Activity(name="Webgroup issues", type=hikari.ActivityType.WATCHING)) diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..e0eefef --- /dev/null +++ b/src/bot.py @@ -0,0 +1,23 @@ +import logging +import sys + +import arc +import hikari + +from src.config import DEBUG, TOKEN + +if TOKEN is None: + print("TOKEN environment variable not set. Exiting.") + sys.exit(1) + +bot = hikari.GatewayBot( + token=TOKEN, + banner=None, + intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT, + logs="DEBUG" if DEBUG else "INFO", +) + +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/") diff --git a/src/config.py b/src/config.py index cf827b9..64b3266 100644 --- a/src/config.py +++ b/src/config.py @@ -1,7 +1,10 @@ -import os -from dotenv import load_dotenv - -load_dotenv() - -TOKEN = os.environ.get("TOKEN") # required -DEBUG = os.environ.get("DEBUG", False) +import os + +from dotenv import load_dotenv + +load_dotenv() + +TOKEN = os.environ.get("TOKEN") # required +DEBUG = os.environ.get("DEBUG", False) + +CHANNEL_IDS = {"lobby": 627542044390457350} diff --git a/src/extensions/boosts.py b/src/extensions/boosts.py new file mode 100644 index 0000000..4ebf266 --- /dev/null +++ b/src/extensions/boosts.py @@ -0,0 +1,50 @@ +import arc +import hikari + +from src.config import CHANNEL_IDS + +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 +) -> 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, + ) + + message = f"{author.display_name} just boosted the server{f' **{content}** times' if content else ''}!" + + if (count := TIER_COUNT[message_type]) is not None: + message += f"{guild.name} has achieved **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) + + +@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 index 9747b9d..655e1d4 100644 --- a/src/extensions/hello_world.py +++ b/src/extensions/hello_world.py @@ -1,102 +1,81 @@ -""" -Example extension with simple commands -""" -import interactions as discord - - -class HelloWorld(discord.Extension): - @discord.slash_command("hello", description="Say hello!") - async def hello(self, ctx: discord.SlashContext): - """A simple hello world command""" - await ctx.send("Hello, world!") - - @discord.slash_command( - "base_command", description="A base command, to expand on" - ) - async def base_command(self, ctx: discord.SlashContext): - ... - - @base_command.subcommand( - "sub_command", sub_cmd_description="A sub command, to expand on" - ) - async def sub_command(self, ctx: discord.SlashContext): - """A simple sub command""" - await ctx.send("Hello, world! This is a sub command") - - @discord.slash_command("options", description="A command with options") - @discord.slash_option( - "option_str", - "A string option", - opt_type=discord.OptionType.STRING, - required=True, - ) - @discord.slash_option( - "option_int", - "An integer option", - opt_type=discord.OptionType.INTEGER, - required=True, - ) - @discord.slash_option( - "option_attachment", - "An attachment option", - opt_type=discord.OptionType.ATTACHMENT, - required=True, - ) - async def options( - self, - ctx: discord.SlashContext, - option_str: str, - option_int: int, - option_attachment: discord.Attachment, - ): - """A command with lots of options""" - embed = discord.Embed( - "There are a lot of options here", - description="Maybe too many", - color=discord.BrandColors.BLURPLE, - ) - embed.set_image(url=option_attachment.url) - embed.add_field( - "String option", - option_str, - inline=False, - ) - embed.add_field( - "Integer option", - str(option_int), - inline=False, - ) - await ctx.send(embed=embed) - - @discord.slash_command("components", description="A command with components") - async def components(self, ctx: discord.SlashContext): - """A command with components""" - await ctx.send( - "Here are some components", - components=discord.spread_to_rows( - discord.Button( - label="Click me!", - custom_id="click_me", - style=discord.ButtonStyle.PRIMARY, - ), - discord.StringSelectMenu( - "Select me!", - "No, select me!", - "Select me too!", - placeholder="I wonder what this does", - min_values=1, - max_values=2, - custom_id="select_me", - ), - ), - ) - - @discord.component_callback("click_me") - async def click_me(self, ctx: discord.ComponentContext): - """A callback for the click me button""" - await ctx.send("You clicked me!") - - @discord.component_callback("select_me") - async def select_me(self, ctx: discord.ComponentContext): - """A callback for the select me menu""" - await ctx.send(f"You selected {' '.join(ctx.values)}") +""" +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) diff --git a/src/extensions/userroles.py b/src/extensions/userroles.py new file mode 100644 index 0000000..563938d --- /dev/null +++ b/src/extensions/userroles.py @@ -0,0 +1,77 @@ +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/main.py b/src/main.py deleted file mode 100644 index a968d45..0000000 --- a/src/main.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Entrypoint script to load extensions and start the client. -""" -import sys -import glob -import interactions as discord -from loguru import logger -from pathlib import Path -from config import DEBUG, TOKEN - -if __name__ == "__main__": - # Configure logging - logger.remove() - logger.add(sys.stdout, level="DEBUG" if DEBUG else "INFO") - - # Verify bot token is set - if not TOKEN: - logger.critical("TOKEN environment variable not set. Exiting.") - sys.exit(1) - - logger.debug(f"Debug mode is {DEBUG}; You can safely ignore this.") - - # Initialize the client - client = discord.Client( - token=TOKEN, - activity=discord.Activity( - name="Webgroup issues", type=discord.ActivityType.WATCHING - ), - auto_defer=True, - sync_ext=True, - ) - - # Enable built-in extensions - client.load_extension("interactions.ext.jurigged") # Hot reloading - - # Load custom extensions - - logger.debug("Working directory: " + str(Path.cwd())) - extensions = [Path(f).stem for f in glob.iglob("/app/src/extensions/*.py")] - logger.debug(f"Found extensions: {extensions}") - - for extension in extensions: - try: - client.load_extension(f"extensions.{extension}") - logger.info(f"Loaded extension: {extension}") - except discord.errors.ExtensionLoadException as e: - logger.exception(f"Failed to load extension: {extension}", exc_info=e) - - # Start the client - - @discord.listen() - async def on_startup(): - logger.info(f"Logged in as {client.user}") - - logger.info("Starting client...") - client.start()