From c8834aa9e336fb57bef0b22593f082da6d1575c8 Mon Sep 17 00:00:00 2001 From: AlexanderHOtt Date: Wed, 28 Dec 2022 21:24:53 -0800 Subject: [PATCH] add a lot of examples --- discord-bot/.env.example | 3 +- discord-bot/CONTRIBUTING.md | 61 ++++ discord-bot/bot/bot.py | 7 +- discord-bot/bot/config.py | 2 + discord-bot/bot/extensions/__init__.py | 5 + discord-bot/bot/extensions/example.py | 406 +++++++++++++++++++++++ discord-bot/bot/extensions/hot_reload.py | 4 +- discord-bot/bot/utils.py | 23 ++ 8 files changed, 504 insertions(+), 7 deletions(-) create mode 100644 discord-bot/bot/extensions/__init__.py create mode 100644 discord-bot/bot/extensions/example.py create mode 100644 discord-bot/bot/utils.py diff --git a/discord-bot/.env.example b/discord-bot/.env.example index 89e50c05..c518010d 100644 --- a/discord-bot/.env.example +++ b/discord-bot/.env.example @@ -1,3 +1,4 @@ TOKEN= DECLARE_GLOBAL_COMMANDS= -OWNER_IDS= \ No newline at end of file +OWNER_IDS= +PREFIX="./" \ No newline at end of file diff --git a/discord-bot/CONTRIBUTING.md b/discord-bot/CONTRIBUTING.md index 089a0c33..d4d8ad3b 100644 --- a/discord-bot/CONTRIBUTING.md +++ b/discord-bot/CONTRIBUTING.md @@ -28,6 +28,67 @@ To test the bot on your own discord server you need to register a discord applic ## Resources +### Structure + +```graphql +.env # Environment variables +.env.example # Example environment variables +CONTRIBUTING.md # This file +dev-requirements.txt # Development requirements +flake8-requirements.txt # Flake8 extensions (for linting) +noxfile.py # Nox session definitions (for formatting, typechecking, linting) +pyproject.toml # Project configuration +README.md # Project readme +requirements.txt # Requirements +templates/ # Message templates + +bot/ +├─ __init__.py +├─ __main__.py # Entrypoint +├─ bot.py # Main bot class +├─ config.py # Configuration and secrets +├─ utils.py # Utility Functions +│ +├─ db/ # Database related code +│ ├─ database.db # SQLite database +│ └─ schema.sql # Database schema +│ +└── extensions/ # Application logic, see https://hikari-lightbulb.readthedocs.io/en/latest/guides/extensions.html + └─ hot_reload.py # Utility for hot reload extension +``` + +### Adding a new command/listener + +1. Create a new file in the `extensions` folder +2. Copy the template below + +```py +# -*- coding: utf-8 -*- +"""My plugin.""" +import lightbulb + +plugin = lightbulb.Plugin("MyPlugin") + +# Add your commands here + +def load(bot: lightbulb.BotApp): + """Add the plugin to the bot.""" + bot.add_plugin(plugin) + + +def unload(bot: lightbulb.BotApp): + """Remove the plugin to the bot.""" + bot.remove_plugin(plugin) +``` + +For example commands and listeners, see [here](/discord-bot/bot/extensions/_example.py) + +### Docs + +Discord + +- [Discord API Reference](https://discord.com/developers/docs/intro) + Main framework - [Hikari Repo](https://github.com/hikari-py/hikari) diff --git a/discord-bot/bot/bot.py b/discord-bot/bot/bot.py index e529cf75..af163545 100644 --- a/discord-bot/bot/bot.py +++ b/discord-bot/bot/bot.py @@ -1,10 +1,10 @@ # -*- coding=utf-8 -*- """Bot logic.""" -import hikari - import aiosqlite +import hikari import lightbulb import miru + from bot.config import Config config = Config.from_env() @@ -12,7 +12,7 @@ config = Config.from_env() bot = lightbulb.BotApp( token=config.token, logs="DEBUG", - prefix="./", + prefix=config.prefix, default_enabled_guilds=config.declare_global_commands, owner_ids=config.owner_ids, intents=hikari.Intents.ALL, @@ -22,7 +22,6 @@ bot = lightbulb.BotApp( @bot.listen() async def on_starting(event: hikari.StartingEvent): """Setup.""" - miru.install(bot) # component handler bot.load_extensions_from("./bot/extensions") # load extensions diff --git a/discord-bot/bot/config.py b/discord-bot/bot/config.py index 5905301c..e3addac9 100644 --- a/discord-bot/bot/config.py +++ b/discord-bot/bot/config.py @@ -19,6 +19,7 @@ class Config: token: str declare_global_commands: int owner_ids: list[int] + prefix: str @classmethod def from_env(cls): @@ -32,4 +33,5 @@ class Config: token=token, declare_global_commands=int(getenv("DECLARE_GLOBAL_COMMANDS", 0)), owner_ids=[int(x) for x in getenv("OWNER_IDS", "").split(",")], + prefix=getenv("PREFIX", "./"), ) diff --git a/discord-bot/bot/extensions/__init__.py b/discord-bot/bot/extensions/__init__.py new file mode 100644 index 00000000..87295d9a --- /dev/null +++ b/discord-bot/bot/extensions/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extensions for the bot. + +See: https://hikari-lightbulb.readthedocs.io/en/latest/guides/extensions.html +""" diff --git a/discord-bot/bot/extensions/example.py b/discord-bot/bot/extensions/example.py new file mode 100644 index 00000000..8ac7fe21 --- /dev/null +++ b/discord-bot/bot/extensions/example.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +"""Example plugins for reference. + +Because this file starts with an `_`, it cannot be loaded by the bot. To see the example plugin in action, rename this file to `example.py`. +""" +import asyncio + +import hikari +import lightbulb +import lightbulb.decorators +import miru +from miru.ext import nav + +plugin = lightbulb.Plugin("ExamplePlugin") + +# To add checks to a plugin, you can use the `@plugin.check` decorator +# or the `plugin.add_check` method. Lightbulb has some built-in checks. +# The check will be called before any command in the plugin is called. +plugin.add_checks(lightbulb.guild_only) + + +# To create a slash command, use the template below +@plugin.command +@lightbulb.command("example", "Example command.") +@lightbulb.implements(lightbulb.SlashCommand) +async def example(ctx: lightbulb.SlashContext): + """Example command.""" + # To send a message, use the `respond` method on `ctx`. + # !!! Be sure to use `await` when calling `respond` !!! + await ctx.respond("Hello, world!") + + +# To add arguments, use the `@lightbulb.option` decorator. +@plugin.command +@lightbulb.option( + "name", # The name of the option. This is what you will use to access the value in `ctx.options.name` + "Your name.", # The description of the option. This will be shown in the slash command menu. + # Whether or not the option is required. + # If `required` is `True`, the user will not be able to use the command without providing a value for this option. + required=False, + default=None, # The default value for the option. If `required` is `True`, this will be ignored. + type=str | None, # The type of the option. This is used to convert the value to the correct type. + # https://hikari-lightbulb.readthedocs.io/en/latest/guides/commands.html#converters-and-slash-command-option-types +) +@lightbulb.option( + "age", + "Your age.", + type=int, + # These are enforced on the client side, so the user won't be able to enter a value outside of the range. + min_value=0, + max_value=100, +) +@lightbulb.option( + "gender", + "Your gender.", + # You can also use `choices` to limit the user to a specific set of values. + # This can be a list of `str`, `int, or `float` + # choices=["Male", "Female", "Other"], + # or a list of `hikari.CommandChoice` objects to have separate option names and values + choices=[ + hikari.CommandChoice(name="male", value="M"), + hikari.CommandChoice(name="female", value="F"), + hikari.CommandChoice(name="other", value="Other"), + ], + type=str, +) +@lightbulb.command("args_example", "Example command with arguments.") +@lightbulb.implements(lightbulb.SlashCommand) +async def args_example(ctx: lightbulb.SlashContext): + """Example command with arguments.""" + name: str | None = ctx.options.name + if name is None: + name = ctx.author.username + age: int = ctx.options.age + gender: str = ctx.options.gender + + await ctx.respond( + f"Hello {ctx.author.mention}! Your name is {name}, you are {age} years old, and your gender is {gender}.", + # in order to actually mention the user, you must pass `user_mentions=True` + # otherwise, the user won't get a notification + user_mentions=True, + ) + + +# To have autocomplete options, add the +# pass `autocomplete=function` to `@lightbulb.option` +# or `autocomplete=True` and mark the function with `@command.autocomplete("option_name")`. +# @autocomplete_example.autocomplete("language") +async def _programming_language_autocomplete( + option: hikari.CommandInteractionOption, interaction: hikari.AutocompleteInteraction +) -> list[str]: + # The `option` argument is the current text that the user typed in. + if not isinstance(option.value, str): + # This will raise a TypeError if `option.value` cannot be converted + option.value = str(option.value) + + # You can query a database, fetch an api, or return any list of strings + # !!! You can return a max of 25 options !!! + langs = [ + "C", + "C++", + "C#", + "CSS", + "Go", + "HTML", + "Java", + "Javascript", + "Kotlin", + "Matlab", + "NoSQL", + "PHP", + "Perl", + "Python", + "R", + "Ruby", + "Rust", + "SQL", + "Scala", + "Swift", + "TypeScript", + "Zig", + ] + return [lang for lang in langs if option.value.lower() in lang.lower()] + + +@plugin.command +@lightbulb.option( + "language", + "Your favorite programming language.", + autocomplete=_programming_language_autocomplete, +) +@lightbulb.command("autocomplete_example", "Autocomplete example.") +@lightbulb.implements(lightbulb.SlashCommand) +async def autocomplete_example(ctx: lightbulb.SlashContext): + """Autocomplete example.""" + await ctx.respond("Your favorite programming language is " + ctx.options.language) + + +# Command groups are like trees +# You can have subcommands, subcommand groups, and subcommand groups with subcommands +# Here is an example diagram: +# /group_example (group) +# subcommand (executable) +# subcommand_group (group) +# subsubcommand (executable) + +# Because those are slash commands, only the leaves (/subcommand and /subsubcommand) are callable. + +# To create a group, use the template below +# 1. Create the command group +@plugin.command +@lightbulb.command("group_example", "Example command group.") +@lightbulb.implements(lightbulb.SlashCommandGroup) +async def group_example(_: lightbulb.SlashContext) -> None: + """Group example.""" + # This will never execute because it is a group + pass + + +# 2. Add a child command +@group_example.child +@lightbulb.command("subcommand", "Example subcommand.") +@lightbulb.implements(lightbulb.SlashSubCommand) +async def subcommand(ctx: lightbulb.SlashContext) -> None: + """An example subcommand.""" + await ctx.respond("invoked `/group_example subcommand`") + + +# 3. Add a sub-group +@group_example.child +@lightbulb.command("subcommand_group", "Example subcommand group.") +@lightbulb.implements(lightbulb.SlashSubGroup) +async def subcommand_group(_: lightbulb.SlashContext) -> None: + """Subcommand group example.""" + # This will never execute because it is a sub-group + pass + + +# 4. Add a child to the sub-group +@subcommand_group.child +@lightbulb.command("subsubcommand", "Example subsubcommand.") +@lightbulb.implements(lightbulb.SlashSubCommand) +async def subsubcommand(ctx: lightbulb.SlashContext) -> None: + """An example subsubcommand.""" + await ctx.respond("invoked `/group_example subcommand_group subsubcommand`") + + +# Event listeners are a way to listen to events from the gateway. +# You can have stand alone event listeners or use `wait_for` to wait for a specific event inside a command / listener. +@plugin.listener(hikari.MemberCreateEvent) +async def on_member_join(event: hikari.MemberCreateEvent) -> None: + """Event listener to welcome new members.""" + guild = event.get_guild() + await event.member.send(f"Welcome to {guild.name if guild else 'the server'}!") + + +# You can also use `wait_for` to wait for a specific event +@plugin.command +@lightbulb.command("wait_for_example", "Example command with `wait_for` and `stream`.") +@lightbulb.implements(lightbulb.SlashCommand) +async def wait_for_example(ctx: lightbulb.SlashContext) -> None: + """Wait for example.""" + await ctx.respond("Send a message!") + + # We can add a predicate to `wait_for` to filter out events + def author_check(e: hikari.MessageCreateEvent) -> bool: + return e.author_id == ctx.author.id + + # You need to wrap wait_for in a try/catch block because it can raise `asyncio.TimeoutError` + try: + event = await ctx.bot.wait_for(hikari.MessageCreateEvent, timeout=10, predicate=author_check) + await ctx.respond(f"You sent: {event.message.content}") + except asyncio.TimeoutError: + await ctx.respond("Too slow!") + # remember to use try/except/finally if you need to clean up any resources + + # You can also use `stream` to listen for events + await ctx.respond("Waiting for guild events...") + with ctx.bot.stream(hikari.Event, timeout=5).filter( + # Only listen for events that have a guild_id and are not bots + lambda e: getattr(e, "guild_id", None) == ctx.guild_id + and getattr(e, "is_human", False) + ) as stream: + async for event in stream: + await ctx.respond(f"New `{event.__class__.__name__}`") + + await ctx.respond("Done!") + + +# You can interact with discord's API using the `rest` attribute on the bot +# This allows you to +# - fetch information about users, channels, guilds, etc. +# - create, edit, and delete messages, channels, threads, roles, categories, etc. +# - add, remove, and edit reactions +@plugin.command +@lightbulb.command("rest_example", "Example command using the `rest` attribute.") +@lightbulb.implements(lightbulb.SlashCommand) +async def rest_example(ctx: lightbulb.SlashContext) -> None: + """Example command using the `rest` attribute.""" + rest = ctx.bot.rest + your_messages = await rest.fetch_messages(ctx.channel_id).filter(lambda m: m.author.id == ctx.author.id).count() + await ctx.respond(f"{your_messages} out of the last 10 messages in this channel were sent by you.") + + +# Context Menus are a way to attach a command to a user or a message. +# By right clicking a user or a User, you can select to execute a command under the "Apps" menu item. +@plugin.command +@lightbulb.command("user_context_menu_example", "Example context menu on a user.") +@lightbulb.implements(lightbulb.UserCommand) +async def user_context_menu_example(ctx: lightbulb.UserContext) -> None: + """User context menu example.""" + user: hikari.Member = ctx.options.target + await ctx.respond(f"Hello {user.mention}!", user_mentions=True) + + +# Same with messages +@plugin.command +@lightbulb.command("message_context_menu_example", "Example context menu on a message.") +@lightbulb.implements(lightbulb.MessageCommand) +async def message_context_menu_example(ctx: lightbulb.MessageContext) -> None: + """Message context menu example.""" + message: hikari.Message = ctx.options.target + await ctx.respond(f"The message length is: {len(message.content or '')}", flags=hikari.MessageFlag.EPHEMERAL) + + +# Components are a way to add interactive buttons to your slash commands. +# We use `miru` to manage components and their callbacks. + +# To create a component, use the template below +# 1. Create the view +class MyView(miru.View): + """An example view with buttons.""" + + @miru.button(label="Rock", emoji="\N{ROCK}", style=hikari.ButtonStyle.PRIMARY) + async def rock_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: + await ctx.respond("Paper!") + + @miru.button(label="Paper", emoji="\N{SCROLL}", style=hikari.ButtonStyle.PRIMARY) + async def paper_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: + await ctx.respond("Scissors!") + + @miru.button(label="Scissors", emoji="\N{BLACK SCISSORS}", style=hikari.ButtonStyle.PRIMARY) + async def scissors_button(self, button: miru.Button, ctx: miru.ViewContext): + await ctx.respond("Rock!") + + @miru.button(emoji="\N{BLACK SQUARE FOR STOP}", style=hikari.ButtonStyle.DANGER, row=2) + async def stop_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: + self.stop() # Stop listening for interactions + + @miru.select( + options=[ + hikari.SelectMenuOption( + label="Thing 1", + value="1", + description="This is a thing", + emoji=hikari.UnicodeEmoji("🗿"), + is_default=True, + ), + hikari.SelectMenuOption( + label="Thing 2", + value="2", + description="This is another thing", + emoji=hikari.UnicodeEmoji("🗿"), + is_default=False, + ), + hikari.SelectMenuOption( + label="Thing 3", + value="3", + description="This is a different thing", + emoji=hikari.UnicodeEmoji("🗿"), + is_default=False, + ), + ], + placeholder="Select some stuff!", + min_values=0, + max_values=2, + row=3, + ) + async def select(self, select: miru.Select, ctx: miru.ViewContext) -> None: + await ctx.respond(f"You selected {select.values}") + + +# 2. Create a command to use the view +@plugin.command +@lightbulb.command("button_example", "Example command with buttons.") +@lightbulb.implements(lightbulb.SlashCommand) +async def button_example(ctx: lightbulb.SlashContext) -> None: + """Wait for example.""" + # 3. Create an instance of the view and start it + view = MyView(timeout=60) + resp = await ctx.respond("Rock Paper Scissors!", components=view) + msg = await resp.message() + await view.start(msg) + await view.wait() + + await ctx.respond("Thank you for playing!") + + +# You can use buttons to create a navigation menu +@plugin.command +@lightbulb.command("nav_example", "Example command with button navigation.", auto_defer=True) +@lightbulb.implements(lightbulb.SlashCommand) +async def navigation_example(ctx: lightbulb.SlashContext) -> None: + """Navigation example.""" + # await ctx.respond(response_type=hikari.ResponseType.DEFERRED_MESSAGE_UPDATE) + embed = hikari.Embed(title="I'm the second page!", description="Also an embed!") + pages = ["I'm the first page!", embed, "I'm the last page!"] + + navigator = nav.NavigatorView(pages=pages, timeout=10) + # You may also pass an interaction object to this function + await navigator.send(ctx.channel_id) + + await navigator.wait() # This is not necessary, but we want to wait anyway + await ctx.respond("Done!") + + +# Miru also has modal support +class MyModal(miru.Modal): + """An example modal.""" + + # Define our modal items + # You can also use Modal.add_item() to add items to the modal after instantiation, just like with views. + name = miru.TextInput(label="Name", placeholder="Enter your name!", required=True) + bio = miru.TextInput(label="Biography", value="Pre-filled content!", style=hikari.TextInputStyle.PARAGRAPH) + + # You can currently only use TextInputs + # https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-modal + + # The callback function is called after the user hits 'Submit' + async def callback(self, context: miru.ModalContext) -> None: + # You can also access the values using ctx.values, Modal.values, or use ctx.get_value_by_id() + await context.respond(f"Your name: `{self.name.value}`\nYour bio: ```{self.bio.value}```") + + +class ModalView(miru.View): + """An example view that opens a modal.""" + + # Create a new button that will invoke our modal + @miru.button(label="Click me!", style=hikari.ButtonStyle.PRIMARY) + async def modal_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: + modal = MyModal(title="Example Title") + # You may also use Modal.send(interaction) if not working with a miru context object. (e.g. slash commands) + # Keep in mind that modals can only be sent in response to interactions. + await ctx.respond_with_modal(modal) + # OR + # await modal.send(ctx.interaction) + + +@plugin.command +@lightbulb.command("modal_example", "Example command with a modal.") +@lightbulb.implements(lightbulb.SlashCommand) +async def modal_example(ctx: lightbulb.SlashContext) -> None: + """Navigation example.""" + view = ModalView() + resp = await ctx.respond("This button triggers a modal!", components=view) + await view.start(await resp.message()) + + +def load(bot: lightbulb.BotApp): + """Add the plugin to the bot.""" + bot.add_plugin(plugin) + + +def unload(bot: lightbulb.BotApp): + """Remove the plugin to the bot.""" + bot.remove_plugin(plugin) diff --git a/discord-bot/bot/extensions/hot_reload.py b/discord-bot/bot/extensions/hot_reload.py index ffb7ea70..b70a22fd 100644 --- a/discord-bot/bot/extensions/hot_reload.py +++ b/discord-bot/bot/extensions/hot_reload.py @@ -14,8 +14,8 @@ EXTENSIONS_FOLDER = "bot/extensions" def _get_extensions() -> list[str]: - # Recursively get all the .py files in the extensions directory. - exts = glob("bot/extensions/**/*.py", recursive=True) + # Recursively get all the .py files in the extensions directory not starting with an `_`. + exts = glob("bot/extensions/**/*[!_].py", recursive=True) # Turn the path into a plugin path ("path/to/extension.py" -> "path.to.extension") return [ext.replace("/", ".").replace("\\", ".").replace(".py", "") for ext in exts] diff --git a/discord-bot/bot/utils.py b/discord-bot/bot/utils.py new file mode 100644 index 00000000..beb81c36 --- /dev/null +++ b/discord-bot/bot/utils.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Utility functions.""" +import typing as t +from datetime import datetime + + +def format_time(dt: datetime, fmt: t.Literal["t", "T", "D", "f", "F", "R"]) -> str: + """Format a datetime object into the discord time format. + + ``` + | t | HH:MM | 16:20 + | T | HH:MM:SS | 16:20:11 + | D | D Mo Yr | 20 April 2022 + | f | D Mo Yr HH:MM | 20 April 2022 16:20 + | F | W, D Mo Yr HH:MM | Wednesday, 20 April 2022 16:20 + | R | relative | in an hour + ``` + """ + match fmt: + case "t" | "T" | "D" | "f" | "F" | "R": + return f"" + case _: + raise ValueError(f"`fmt` must be 't', 'T', 'D', 'f', 'F' or 'R', not {fmt}")