Skip to content

Commit

Permalink
feat: Command system prototype using Pydantic, disconnect Tanjun, res…
Browse files Browse the repository at this point in the history
…tructure more lifecycle into component_manager, general restructuring and rewriting
  • Loading branch information
pmdevita committed Feb 2, 2025
1 parent 99e6dce commit b5cbc31
Show file tree
Hide file tree
Showing 18 changed files with 2,136 additions and 1,182 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ repos:
# supported by your project here, or alternatively use
# pre-commit's default_language_version, see
# https://pre-commit.com/#top_level-default_language_version
language_version: python3.11
language_version: python3.12
11 changes: 7 additions & 4 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
[Documentation](https://pmdevita.github.io/hikari-atsume/)

An opinionated Discord bot framework inspired by Django and built on
top of [Hikari](https://github.com/hikari-py/hikari), [Tanjun](https://github.com/FasterSpeeding/Tanjun),
top of [Hikari](https://github.com/hikari-py/hikari), ~~[Tanjun](https://github.com/FasterSpeeding/Tanjun),~~
[Ormar](https://github.com/collerek/ormar), and [Alembic](https://alembic.sqlalchemy.org/en/latest/).

Atsume is in alpha and breaking changes should be expected. If you have any feedback or advice, feel free
to find me in the [Hikari Discord](https://discord.gg/Jx4cNGG).
Atsume is very much still in alpha and breaking changes should be expected. Progress is a bit slow
due to my other responsibilities but it'll eventually be working lol.
If you have any feedback or advice, feel free to find me in the [Hikari Discord](https://discord.gg/Jx4cNGG).


## Features
Expand All @@ -32,7 +33,7 @@ to find me in the [Hikari Discord](https://discord.gg/Jx4cNGG).

Create a new project directory and install hikari-atsume
(make sure you are using Python 3.10+. If you are using Poetry,
your Python dependency should be `python = "^3.10,<3.12"`)
your Python dependency should be `python = "^3.10,<3.13"`)

```shell
# Install with your preferred database backend, SQLite recommended for beginners
Expand Down Expand Up @@ -199,4 +200,6 @@ python manage.py run
- The [Django](https://www.djangoproject.com/) and [django-stubs](https://github.com/typeddjango/django-stubs) projects for their amazing work and some
code that I borrowed.

hikari-atsume's name means "to gather light", which was chosen to reflect how it can gather multiple
components and extensions into a single bot, and as a lyric from the J-pop song [Kimigairu by Ikimonogakari](https://www.youtube.com/watch?v=fLcSUrlS9us).

35 changes: 21 additions & 14 deletions atsume/alembic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def get_alembic_config(
cfg.set_main_option("script_location", str(Path(__file__).parent / "template"))
# We can use either Atsume's configured database settings, or an in memory database
# The in-memory database is used for making migrations since using a real one would
# require that database to be up to date on migrations.
# require that database to be up-to-date on migrations.
if in_memory:
cfg.set_main_option("sqlalchemy.url", "sqlite://")
cfg.engine = sqlalchemy.create_engine("sqlite://")
Expand All @@ -68,7 +68,6 @@ def get_alembic_config(
# The env.py script may want to also pull certain things from the ComponentConfig
cfg.component_config = component_config
cfg.app_metadata = component_config._model_metadata
# TODO: May not be needed anymore now that make migrations simulates the database and for each component
# Migrations also needs awareness for all tables used by the bot
cfg.all_tables = get_all_tables()

Expand All @@ -84,32 +83,40 @@ def get_alembic_config(

# Get the mapping of the model names to table names that reflects the
# current state of the migration scripts.

cfg.previous_model_mapping = get_model_table_names(scripts)

# Get the current mapping of model names to table names.
current_models = {
model.Meta._qual_name: model.Meta.tablename for model in component_config.models
}

cfg.add_models, cfg.remove_models = get_model_ops(
cfg.previous_model_mapping, current_models
)
# Renames are figured out in env.py after table schemas are computed.
cfg.rename_models = {}

get_formatting(cfg)

return cfg


def get_model_ops(
previous_models: dict[str, str], current_models: dict[str, str]
) -> tuple[dict[str, str], dict[str, str]]:
# Determine the changes that we are making to these models.
add_models = {}
remove_models = {}
# All of the models not in the previous model mapping must be models we are adding
for model_name, table_name in current_models.items():
if model_name not in cfg.previous_model_mapping:
if model_name not in previous_models:
add_models[model_name] = table_name
# All of the models not in the current model mapping must be models we are dropping
for model_name, table_name in cfg.previous_model_mapping.items():
for model_name, table_name in previous_models.items():
if model_name not in current_models:
remove_models[model_name] = table_name

cfg.add_models = add_models
cfg.remove_models = remove_models
# Renames are figured out in env.py after table schemas are computed.
cfg.rename_models = {}

get_formatting(cfg)

return cfg
return add_models, remove_models


def get_model_table_names(scripts: ScriptDirectory) -> dict[str, str]:
Expand All @@ -126,7 +133,7 @@ def get_model_table_names(scripts: ScriptDirectory) -> dict[str, str]:
# Order here might eliminate instances of models stepping on each other's toes
for key in migration.module.remove_models.keys():
del previous_models[key]
# Renames are before: after
# Renames are { before: after }
for key, value in migration.module.rename_models.items():
previous_models[value] = previous_models[key]
del previous_models[key]
Expand Down
50 changes: 24 additions & 26 deletions atsume/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import hupper # type: ignore
import tanjun

from atsume.command.model import Command
from atsume.settings import settings
from atsume.component.decorators import (
BaseCallback,
Expand Down Expand Up @@ -56,6 +57,8 @@ def initialize_atsume(bot_module: str) -> None:

# This needs to get done before we load any database models
database._create_database()
component_manager._setting_init()
load_components()


def initialize_discord() -> typing.Tuple[hikari.GatewayBot, tanjun.Client]:
Expand All @@ -75,12 +78,13 @@ def initialize_discord() -> typing.Tuple[hikari.GatewayBot, tanjun.Client]:

global_commands = not settings.DEBUG and settings.GLOBAL_COMMANDS

client = tanjun.Client.from_gateway_bot(
bot, declare_global_commands=global_commands, mention_prefix=False
)
if settings.MESSAGE_PREFIX:
client.add_prefix(settings.MESSAGE_PREFIX)
return bot, client
# client = tanjun.Client.from_gateway_bot(
# bot, declare_global_commands=global_commands, mention_prefix=False
# )

# if settings.MESSAGE_PREFIX:
# client.add_prefix(settings.MESSAGE_PREFIX)
return bot # , client


def create_bot(
Expand All @@ -100,52 +104,45 @@ def create_bot(
:return: An initialized :py:class:`hikari.GatewayBot` object.
"""
initialize_atsume(bot_module)
bot, client = initialize_discord()
attach_extensions(client)
load_components(client)
bot = initialize_discord()
# attach_extensions(client)
# load_components()
return bot


def run_bot(bot: hikari.GatewayBot) -> None:
bot.run(asyncio_debug=settings.DEBUG)
def run_bot() -> None:
component_manager.bot.run(asyncio_debug=settings.DEBUG)


@cli.command("run")
@click.pass_obj
def start_bot(bot: hikari.GatewayBot) -> None:
def start_bot() -> None:
"""
The project CLI command to run the bot
:param bot: The bot instance to run.
"""
if settings.DEBUG:
# IDK if this is necessary but might reduce overhead
del bot
gc.collect()
reloader = hupper.start_reloader("atsume.bot.autoreload_start_bot")
else:
run_bot(bot)
run_bot()


def autoreload_start_bot() -> None:
bot = create_bot(os.environ["ATSUME_SETTINGS_MODULE"])
run_bot(bot)
run_bot()


def load_components(client: tanjun.abc.Client) -> None:
def load_components() -> None:
"""
Load the ComponentConfigs as dictated by the settings and then load the component for each of them.
:param client: The Tanjun Client to load the components on to.
"""
component_manager._load_components()
# component_manager._load_components()
for component_config in component_manager.component_configs:
load_component(client, component_config)
load_component(component_config)


def load_component(
client: tanjun.abc.Client, component_config: ComponentConfig
) -> None:
def load_component(component_config: ComponentConfig) -> None:
"""
Load a Component from its config, attach permissions, and attach it to the client.
Expand Down Expand Up @@ -184,4 +181,5 @@ def load_component(
component.add_schedule(value.as_time_schedule())
elif isinstance(value, AtsumeIntervalSchedule):
component.add_schedule(value.as_interval())
client.add_component(component)
if isinstance(value, Command):
component_config.commands.append(value)
4 changes: 2 additions & 2 deletions atsume/cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def cli(ctx: click.Context) -> None:
"""
assert isinstance(ctx, CLIContext)
bot_module = os.environ["ATSUME_SETTINGS_MODULE"]
from atsume.bot import create_bot
from atsume.bot import initialize_atsume

ctx.obj = create_bot(bot_module)
initialize_atsume(bot_module)


cli.context_class = CLIContext
1 change: 1 addition & 0 deletions atsume/command/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .model import CommandModel
40 changes: 40 additions & 0 deletions atsume/command/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Annotated, Any

import hikari
from hikari import Snowflakeish, GatewayBot, OptionType
from pydantic import (
BeforeValidator,
GetCoreSchemaHandler,
GetPydanticSchema,
ValidationInfo,
)
from pydantic_core import CoreSchema, core_schema

# from pydantic_async_validation import ValidationInfo
from hikari.api import cache

from atsume.command.context import Context


def validate_member(tp, handler):
def validate(
value: Snowflakeish | hikari.Member, info: ValidationInfo
) -> hikari.Member:
return value

return core_schema.with_info_before_validator_function(
validate, core_schema.int_schema()
)


Member = Annotated[hikari.Member, GetPydanticSchema(validate_member)]


HIKARI_TO_OPTION_TYPE: dict[Any, OptionType] = {
hikari.Member: OptionType.USER,
hikari.GuildChannel: OptionType.CHANNEL,
int: OptionType.INTEGER,
float: OptionType.FLOAT,
str: OptionType.STRING,
bool: OptionType.BOOLEAN,
}
106 changes: 106 additions & 0 deletions atsume/command/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import asyncio
import importlib
import logging
import shlex

import hikari

from typing import TYPE_CHECKING, cast

from hikari import (
StartingEvent,
InteractionCreateEvent,
CommandInteraction,
InteractionType,
ResponseType,
MessageCreateEvent,
)

from atsume.command.context import CommandInteractionContext
from atsume.command.model import Command, command_model_context
from atsume.settings import settings
from atsume.utils.interactions import interaction_options_to_objects

if TYPE_CHECKING:
from atsume.component.manager import ComponentManager

logger = logging.getLogger(__name__)


class CommandManager:
def __init__(self, manager: "ComponentManager"):
self.manager = manager
self.bot = self.manager.bot
self.manager.bot.subscribe(StartingEvent, self._on_starting)
self.manager.bot.subscribe(hikari.InteractionCreateEvent, self._on_interaction)
self.manager.bot.subscribe(hikari.MessageCreateEvent, self._on_message)

self.commands = {}
for component in self.manager.component_configs:
# Create the component and load the commands into it
module = importlib.import_module(component.commands_path)
module_attrs = vars(module)

for value in module_attrs.values():
if isinstance(value, Command):
self.commands[value.name] = value

async def _on_starting(self, event: StartingEvent):
logger.info("Registering commands...")

self.application = await self.manager.bot.rest.fetch_application()

commands = [i.as_slash_command(self.bot) for i in self.commands.values()]

async for guild in self.bot.rest.fetch_my_guilds():
await self.bot.rest.set_application_commands(
self.application, commands, guild.id
)

async def _on_interaction(self, event: InteractionCreateEvent):
print(event)
match event.interaction.type:
case InteractionType.APPLICATION_COMMAND:
await self._on_application_command(
cast(CommandInteraction, event.interaction)
)
case _:
pass

async def _on_application_command(self, interaction: CommandInteraction):
command = self.commands.get(interaction.command_name, None)

if command is None:
await interaction.create_initial_response(
ResponseType.MESSAGE_CREATE, "Unknown command."
)
return

await interaction.create_initial_response(ResponseType.DEFERRED_MESSAGE_CREATE)

try:
kwargs = await interaction_options_to_objects(self.bot, interaction)

options = command.command_model(**kwargs)
ctx = CommandInteractionContext(self.bot, interaction)

await command(ctx, options)

if not ctx.has_replied:
logger.warning(
f"Command {command} did not respond to the interaction command!"
)
await interaction.edit_initial_response("The command did not respond.")
except:
await interaction.edit_initial_response("An error has occurred.")
raise

async def _on_message(self, event: MessageCreateEvent):
if event.message.content is None:
return

if not event.message.content.startswith(settings.MESSAGE_PREFIX):
return

command = shlex.split(event.message.content)
print(command)
Loading

0 comments on commit b5cbc31

Please sign in to comment.