Skip to content

Commit

Permalink
Merge branch 'main' into 27-uptime--koyakonsta
Browse files Browse the repository at this point in the history
  • Loading branch information
koyakonsta authored Feb 1, 2024
2 parents f1ce6f9 + 3cfe573 commit 9e2d54c
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 155 deletions.
74 changes: 44 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=<paste Discord bot token here>
> ```
> 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=<Discord bot token here>
```
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`.
5 changes: 2 additions & 3 deletions src/__main__.py
Original file line number Diff line number Diff line change
@@ -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))
16 changes: 14 additions & 2 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://webgroup-issues.redbrick.dcu.ie/>."

await ctx.respond(f"❌ Blockbot encountered an unhandled exception. {message}")
logging.error(exc)

raise exc
6 changes: 4 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
66 changes: 36 additions & 30 deletions src/extensions/boosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions src/extensions/hello_world.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,22 @@
import arc
import hikari

plugin = arc.GatewayPlugin(name="hello_world")

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(
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -67,15 +70,15 @@ 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(
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)
83 changes: 83 additions & 0 deletions src/extensions/user_roles.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 9e2d54c

Please sign in to comment.