diff --git a/.env.example b/.env.example index c987c47..cdfa8e4 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,14 @@ TOKEN= INSTANCE= OWNER_IDS= + XP_GAIN_PER_MESSAGE= XP_GAIN_COOLDOWN= + DBX_OAUTH2_REFRESH_TOKEN= DBX_APP_KEY= DBX_APP_SECRET= + MARIADB_USER= MARIADB_PASSWORD= MARIADB_ROOT_PASSWORD= diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index e67df16..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 53128fd..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Create and Publish Docker Image CI - - -on: - push: - branches: [ "main" ] - tags: [ "v*.*.*" ] - pull_request: - -jobs: - - docker: - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: | - wlinator/luminara - ghcr.io/wlinator/luminara - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v6 - with: - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 97be63e..1ee9701 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,11 @@ repos: rev: v4.6.0 hooks: - id: check-yaml + - id: sort-simple-yaml + files: settings.yaml - id: check-json + - id: pretty-format-json + args: [--autofix] - id: check-toml - repo: https://github.com/asottile/add-trailing-comma @@ -16,7 +20,7 @@ repos: hooks: # Run the linter. - id: ruff - args: [ --fix ] + args: [--fix] # Run the formatter. - id: ruff-format @@ -25,11 +29,6 @@ repos: hooks: - id: gitleaks - - repo: https://github.com/hija/clean-dotenv - rev: v0.0.7 - hooks: - - id: clean-dotenv - - repo: https://github.com/asottile/pyupgrade rev: v3.16.0 hooks: diff --git a/Client.py b/Client.py deleted file mode 100644 index 0bae0a0..0000000 --- a/Client.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -import platform - -import discord -from discord.ext import bridge -from loguru import logger - -from lib.constants import CONST - - -class LumiBot(bridge.Bot): - async def on_ready(self): - """ - Called when the bot is ready. - - Logs various information about the bot and the environment it is running on. - Note: This function isn't guaranteed to only be called once. The event is called when a RESUME request fails. - """ - logger.info(f"{CONST.TITLE} v{CONST.VERSION}") - logger.info(f"Logged in with ID {self.user.id if self.user else 'Unknown'}") - logger.info(f"discord.py API version: {discord.__version__}") - logger.info(f"Python version: {platform.python_version()}") - logger.info(f"Running on: {platform.system()} {platform.release()} ({os.name})") - - if self.owner_ids: - for owner_id in self.owner_ids: - logger.info(f"Added bot admin: {owner_id}") - - async def process_commands(self, message: discord.Message): - """ - Processes commands sent by users. - - Args: - message (discord.Message): The message object containing the command. - """ - if message.author.bot: - return - - ctx = await self.get_context(message) - - if ctx.command: - # await ctx.trigger_typing() - await self.invoke(ctx) diff --git a/Dockerfile b/Dockerfile index ba2bc4b..3d6ba34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ RUN rm -rf .venv ENV LANG=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8 -CMD [ "poetry", "run", "python", "./Luminara.py" ] \ No newline at end of file +CMD [ "poetry", "run", "python", "-O", "./main.py" ] \ No newline at end of file diff --git a/Luminara.py b/Luminara.py deleted file mode 100644 index 4872471..0000000 --- a/Luminara.py +++ /dev/null @@ -1,100 +0,0 @@ -import os -import sys - -import discord -from discord.ext import commands -from loguru import logger - -import Client -import services.config_service -import services.help_service -from db.database import run_migrations -from lib.constants import CONST -from lib.exceptions.LumiExceptions import Blacklisted -from services.blacklist_service import BlacklistUserService - -# Remove the default logger configuration -logger.remove() - -# Add a new logger configuration with colors and a short datetime format -log_format = ( - "{time:YY-MM-DD HH:mm:ss} | " - "{level: <8} | " - # "{name}:{function}:{line} - " - "{message}" -) -logger.add(sys.stdout, format=log_format, colorize=True, level="DEBUG") - - -async def get_prefix(bot, message): - extras = services.config_service.GuildConfig.get_prefix(message) - return commands.when_mentioned_or(*extras)(bot, message) - - -client = Client.LumiBot( - owner_ids=CONST.OWNER_IDS, - command_prefix=get_prefix, - intents=discord.Intents.all(), - status=discord.Status.online, - help_command=services.help_service.LumiHelp(), -) - - -@client.check -async def blacklist_check(ctx): - if BlacklistUserService.is_user_blacklisted(ctx.author.id): - raise Blacklisted - return True - - -def load_modules(): - loaded = set() - - # Load event listeners (handlers) and command cogs (modules) - for directory in ["handlers", "modules"]: - directory_path = os.path.join(os.getcwd(), directory) - if not os.path.isdir(directory_path): - continue - - items = ( - [ - d - for d in os.listdir(directory_path) - if os.path.isdir(os.path.join(directory_path, d)) - ] - if directory == "modules" - else [f[:-3] for f in os.listdir(directory_path) if f.endswith(".py")] - ) - - for item in items: - if item in loaded: - continue - - try: - client.load_extension(f"{directory}.{item}") - loaded.add(item) - logger.debug(f"{item.upper()} loaded.") - - except Exception as e: - logger.exception(f"Failed to load {item.upper()}: {e}") - - -if __name__ == "__main__": - """ - This code is only ran when Lumi.py is the primary module, - so NOT when main is imported from a cog. (sys.modules) - """ - - logger.info("LUMI IS BOOTING") - - # Run database migrations - run_migrations() - - # load command and listener cogs - load_modules() - - if not CONST.TOKEN: - logger.error("token is not set in .env") - exit(1) - - client.run(CONST.TOKEN) diff --git a/README.md b/README.md index 71eca41..b205343 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Self-hosting refers to running Luminara on your own server or computer, rather than using the publicly hosted version. This approach offers the ability to manage your own instance of the bot and give it a custom name and avatar. -**Note:** From `v2.9.0` and onward, Lumi now utilizes a [settings.yaml](settings/settings.yaml) file to manage configuration settings. This allows you to customize your bot's behavior without needing to modify the source code itself. +**Note:** From `v2.9.0` and onward, Lumi now utilizes a [settings.yaml](settings.yaml) file to manage configuration settings. This allows you to customize your bot's behavior without needing to modify the source code itself. ### Requirements diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/database.py b/db/database.py index 0277edc..14868b1 100644 --- a/db/database.py +++ b/db/database.py @@ -1,12 +1,13 @@ import os import pathlib import re +from typing import Any import mysql.connector from loguru import logger from mysql.connector import pooling -from lib.constants import CONST +from lib.const import CONST def create_connection_pool(name: str, size: int) -> pooling.MySQLConnectionPool: @@ -27,37 +28,33 @@ try: _cnxpool = create_connection_pool("core-pool", 25) except mysql.connector.Error as e: logger.critical(f"Couldn't create the MySQL connection pool: {e}") - raise e + raise -def execute_query(query, values=None): - with _cnxpool.get_connection() as conn: - with conn.cursor() as cursor: - cursor.execute(query, values) - conn.commit() - return cursor +def execute_query(query: str, values: tuple[Any, ...] | None = None) -> None: + with _cnxpool.get_connection() as conn, conn.cursor() as cursor: + cursor.execute(query, values) + conn.commit() + return cursor -def select_query(query, values=None): - with _cnxpool.get_connection() as conn: - with conn.cursor() as cursor: - cursor.execute(query, values) - return cursor.fetchall() +def select_query(query: str, values: tuple[Any, ...] | None = None) -> list[Any]: + with _cnxpool.get_connection() as conn, conn.cursor() as cursor: + cursor.execute(query, values) + return cursor.fetchall() -def select_query_one(query, values=None): - with _cnxpool.get_connection() as conn: - with conn.cursor() as cursor: - cursor.execute(query, values) - output = cursor.fetchone() - return output[0] if output else None +def select_query_one(query: str, values: tuple[Any, ...] | None = None) -> Any | None: + with _cnxpool.get_connection() as conn, conn.cursor() as cursor: + cursor.execute(query, values) + output = cursor.fetchone() + return output[0] if output else None -def select_query_dict(query, values=None): - with _cnxpool.get_connection() as conn: - with conn.cursor(dictionary=True) as cursor: - cursor.execute(query, values) - return cursor.fetchall() +def select_query_dict(query: str, values: tuple[Any, ...] | None = None) -> list[dict[str, Any]]: + with _cnxpool.get_connection() as conn, conn.cursor(dictionary=True) as cursor: + cursor.execute(query, values) + return cursor.fetchall() def run_migrations(): @@ -66,10 +63,9 @@ def run_migrations(): [f for f in os.listdir(migrations_dir) if f.endswith(".sql")], ) - with _cnxpool.get_connection() as conn: - with conn.cursor() as cursor: - # Create migrations table if it doesn't exist - cursor.execute(""" + with _cnxpool.get_connection() as conn, conn.cursor() as cursor: + # Create migrations table if it doesn't exist + cursor.execute(""" CREATE TABLE IF NOT EXISTS migrations ( id INT AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL, @@ -77,39 +73,38 @@ def run_migrations(): ) """) - for migration_file in migration_files: - # Check if migration has already been applied + for migration_file in migration_files: + # Check if migration has already been applied + cursor.execute( + "SELECT COUNT(*) FROM migrations WHERE filename = %s", + (migration_file,), + ) + if cursor.fetchone()[0] > 0: + logger.debug( + f"Migration {migration_file} already applied, skipping.", + ) + continue + + # Read and execute migration file + migration_sql = pathlib.Path(migrations_dir) / migration_file + migration_sql = migration_sql.read_text() + try: + # Split the migration file into individual statements + statements = re.split(r";\s*$", migration_sql, flags=re.MULTILINE) + for statement in statements: + if statement.strip(): + cursor.execute(statement) + + # Record successful migration cursor.execute( - "SELECT COUNT(*) FROM migrations WHERE filename = %s", + "INSERT INTO migrations (filename) VALUES (%s)", (migration_file,), ) - if cursor.fetchone()[0] > 0: - logger.debug( - f"Migration {migration_file} already applied, skipping.", - ) - continue + conn.commit() + logger.debug(f"Successfully applied migration: {migration_file}") + except mysql.connector.Error as e: + conn.rollback() + logger.error(f"Error applying migration {migration_file}: {e}") + raise - # Read and execute migration file - migration_sql = pathlib.Path( - os.path.join(migrations_dir, migration_file), - ).read_text() - try: - # Split the migration file into individual statements - statements = re.split(r";\s*$", migration_sql, flags=re.MULTILINE) - for statement in statements: - if statement.strip(): - cursor.execute(statement) - - # Record successful migration - cursor.execute( - "INSERT INTO migrations (filename) VALUES (%s)", - (migration_file,), - ) - conn.commit() - logger.debug(f"Successfully applied migration: {migration_file}") - except mysql.connector.Error as e: - conn.rollback() - logger.error(f"Error applying migration {migration_file}: {e}") - raise - - logger.debug("All migrations completed.") + logger.success("All database migrations completed.") diff --git a/db/migrations/__init__.py b/db/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/migrations/v2_5_8_init.sql b/db/migrations/v2_5_8_init.sql index 8cd15e0..9dfb09f 100644 --- a/db/migrations/v2_5_8_init.sql +++ b/db/migrations/v2_5_8_init.sql @@ -106,4 +106,4 @@ CREATE TABLE IF NOT EXISTS blacklist_user ( timestamp TIMESTAMP NOT NULL DEFAULT NOW(), active BOOLEAN DEFAULT TRUE, PRIMARY KEY (user_id) -); +); \ No newline at end of file diff --git a/db/migrations/v2_5_9_reactions.sql b/db/migrations/v2_5_9_reactions.sql index ca3839f..ab0cba3 100644 --- a/db/migrations/v2_5_9_reactions.sql +++ b/db/migrations/v2_5_9_reactions.sql @@ -18,4 +18,4 @@ CREATE TABLE IF NOT EXISTS custom_reactions ( -- Create indexes to speed up lookups CREATE OR REPLACE INDEX idx_custom_reactions_guild_id ON custom_reactions(guild_id); CREATE OR REPLACE INDEX idx_custom_reactions_creator_id ON custom_reactions(creator_id); -CREATE OR REPLACE INDEX idx_custom_reactions_trigger_text ON custom_reactions(trigger_text); +CREATE OR REPLACE INDEX idx_custom_reactions_trigger_text ON custom_reactions(trigger_text); \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c24272d..934e6ef 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: core: - image: ghcr.io/wlinator/luminara:2 # Remove "ghcr.io/" if you want to use the Docker Hub image. + image: ghcr.io/wlinator/luminara:3 # Remove "ghcr.io/" if you want to use the Docker Hub image. container_name: lumi-core restart: always env_file: @@ -25,4 +25,4 @@ services: test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ] interval: 5s timeout: 10s - retries: 5 + retries: 5 \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/error.py b/handlers/error.py new file mode 100644 index 0000000..64eb9c1 --- /dev/null +++ b/handlers/error.py @@ -0,0 +1,111 @@ +import sys +import traceback +from typing import Any + +import discord +from discord import app_commands +from discord.ext import commands +from loguru import logger + +from lib import exceptions +from lib.const import CONST + +error_map: dict[type[Exception], str] = { + commands.BotMissingPermissions: CONST.STRINGS["error_bot_missing_permissions_description"], + commands.MissingPermissions: CONST.STRINGS["error_missing_permissions_description"], + commands.NoPrivateMessage: CONST.STRINGS["error_no_private_message_description"], + commands.NotOwner: CONST.STRINGS["error_not_owner_unknown"], + commands.PrivateMessageOnly: CONST.STRINGS["error_private_message_only_description"], + exceptions.BirthdaysDisabled: CONST.STRINGS["error_birthdays_disabled_description"], +} + + +async def on_error(event: str, *args: Any, **kwargs: Any) -> None: + logger.exception( + f"on_error INFO: errors.event.{event} | '*args': {args} | '**kwargs': {kwargs}", + ) + logger.exception(f"on_error EXCEPTION: {sys.exc_info()}") + traceback.print_exc() + + +async def log_command_error( + user_name: str, + command_name: str | None, + guild_id: int | None, + error: commands.CommandError | commands.CheckFailure | app_commands.AppCommandError, + command_type: str, +) -> None: + if isinstance(error, commands.NotOwner | exceptions.Blacklisted): + return + + log_msg = f"{user_name} executed {command_type}{command_name or 'Unknown'}" + + log_msg += " in DMs" if guild_id is None else f" | guild: {guild_id}" + + if CONST.INSTANCE == "dev": + logger.exception( + f"{log_msg} | {error.__module__}.{error.__class__.__name__} | {''.join(traceback.format_exception(type(error), error, error.__traceback__))}", + ) + else: + logger.error(f"{log_msg} | {error.__module__}.{error.__class__.__name__}") + + +class ErrorHandler(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + async def cog_load(self): + tree = self.bot.tree + self._old_tree_error = tree.on_error + tree.on_error = self.on_app_command_error + + async def cog_unload(self): + tree = self.bot.tree + tree.on_error = self._old_tree_error + + async def on_app_command_error( + self, + interaction: discord.Interaction, + error: app_commands.AppCommandError, + ) -> None: + if isinstance(error, commands.CommandNotFound | exceptions.Blacklisted): + return + + await log_command_error( + user_name=interaction.user.name, + command_name=interaction.command.qualified_name if interaction.command else None, + guild_id=interaction.guild.id if interaction.guild else None, + error=error, + command_type="/", + ) + + error_msg = error_map.get(type(error), str(error)) + await interaction.response.send_message(content=f"❌ **{interaction.user.name}** {error_msg}", ephemeral=True) + + @commands.Cog.listener() + async def on_command_error( + self, + ctx: commands.Context[commands.Bot], + error: commands.CommandError | commands.CheckFailure, + ) -> None: + if isinstance(error, commands.CommandNotFound | exceptions.Blacklisted): + return + + await log_command_error( + user_name=ctx.author.name, + command_name=ctx.command.qualified_name if ctx.command else None, + guild_id=ctx.guild.id if ctx.guild else None, + error=error, + command_type=".", + ) + + error_msg = error_map.get(type(error), str(error)) + await ctx.send(content=f"❌ **{ctx.author.name}** {error_msg}") + + @commands.Cog.listener() + async def on_error(self, event: str, *args: Any, **kwargs: Any) -> None: + await on_error(event, *args, **kwargs) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(ErrorHandler(bot)) diff --git a/handlers/error_handler.py b/handlers/error_handler.py deleted file mode 100644 index 8833371..0000000 --- a/handlers/error_handler.py +++ /dev/null @@ -1,121 +0,0 @@ -import sys -import traceback - -from discord.ext import commands -from discord.ext.commands import Cog -from loguru import logger - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions import LumiExceptions - - -async def on_command_error(ctx, error): - if isinstance(error, (commands.CommandNotFound, LumiExceptions.Blacklisted)): - return - - author_text = None - description = None - footer_text = None - ephemeral = False - - if isinstance(error, commands.MissingRequiredArgument): - author_text = CONST.STRINGS["error_bad_argument_author"] - description = CONST.STRINGS["error_bad_argument_description"].format(str(error)) - - elif isinstance(error, commands.BadArgument): - author_text = CONST.STRINGS["error_bad_argument_author"] - description = CONST.STRINGS["error_bad_argument_description"].format(str(error)) - - elif isinstance(error, commands.BotMissingPermissions): - author_text = CONST.STRINGS["error_bot_missing_permissions_author"] - description = CONST.STRINGS["error_bot_missing_permissions_description"] - - elif isinstance(error, commands.CommandOnCooldown): - author_text = CONST.STRINGS["error_command_cooldown_author"] - description = CONST.STRINGS["error_command_cooldown_description"].format( - int(error.retry_after // 60), - int(error.retry_after % 60), - ) - ephemeral = True - - elif isinstance(error, commands.MissingPermissions): - author_text = CONST.STRINGS["error_missing_permissions_author"] - description = CONST.STRINGS["error_missing_permissions_description"] - - elif isinstance(error, commands.NoPrivateMessage): - author_text = CONST.STRINGS["error_no_private_message_author"] - description = CONST.STRINGS["error_no_private_message_description"] - - elif isinstance(error, commands.NotOwner): - author_text = CONST.STRINGS["error_not_owner_author"] - description = CONST.STRINGS["error_not_owner_description"] - - elif isinstance(error, commands.PrivateMessageOnly): - author_text = CONST.STRINGS["error_private_message_only_author"] - description = CONST.STRINGS["error_private_message_only_description"] - - elif isinstance(error, LumiExceptions.BirthdaysDisabled): - author_text = CONST.STRINGS["error_birthdays_disabled_author"] - description = CONST.STRINGS["error_birthdays_disabled_description"] - footer_text = CONST.STRINGS["error_birthdays_disabled_footer"] - - elif isinstance(error, LumiExceptions.LumiException): - author_text = CONST.STRINGS["error_lumi_exception_author"] - description = CONST.STRINGS["error_lumi_exception_description"].format( - str(error), - ) - - else: - author_text = CONST.STRINGS["error_unknown_error_author"] - description = CONST.STRINGS["error_unknown_error_description"] - - await ctx.respond( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=author_text, - description=description, - footer_text=footer_text, - ), - ephemeral=ephemeral, - ) - - -async def on_error(event: str, *args, **kwargs) -> None: - logger.exception( - f"on_error INFO: errors.event.{event} | '*args': {args} | '**kwargs': {kwargs}", - ) - logger.exception(f"on_error EXCEPTION: {sys.exc_info()}") - traceback.print_exc() - - -class ErrorListener(Cog): - def __init__(self, client): - self.client = client - - @staticmethod - async def log_command_error(ctx, error, command_type): - log_msg = ( - f"{ctx.author.name} executed {command_type}{ctx.command.qualified_name}" - ) - - log_msg += " in DMs" if ctx.guild is None else f" | guild: {ctx.guild.name} " - logger.warning(f"{log_msg} | FAILED: {error}") - - @Cog.listener() - async def on_command_error(self, ctx, error) -> None: - await on_command_error(ctx, error) - await self.log_command_error(ctx, error, ".") - - @Cog.listener() - async def on_application_command_error(self, ctx, error) -> None: - await on_command_error(ctx, error) - await self.log_command_error(ctx, error, "/") - - @Cog.listener() - async def on_error(self, event: str, *args, **kwargs) -> None: - await on_error(event, *args, **kwargs) - - -def setup(client): - client.add_cog(ErrorListener(client)) diff --git a/handlers/event.py b/handlers/event.py new file mode 100644 index 0000000..e901a2f --- /dev/null +++ b/handlers/event.py @@ -0,0 +1,98 @@ +import discord +from discord.ext import commands +from loguru import logger + +from services.blacklist_service import BlacklistUserService +from services.config_service import GuildConfig +from ui.config import create_boost_embed, create_greet_embed + + +class EventHandler(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + if BlacklistUserService.is_user_blacklisted(member.id): + return + + config = GuildConfig(member.guild.id) + + if not config.welcome_channel_id: + return + + embed = create_greet_embed( + user_name=member.name, + user_avatar_url=member.display_avatar.url, + guild_name=member.guild.name, + template=config.welcome_message, + ) + + try: + channel = member.guild.get_channel(config.welcome_channel_id) + if isinstance(channel, discord.TextChannel): + await channel.send( + embed=embed, + content=member.mention, + ) + except Exception as e: + logger.warning( + f"Greet message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}", + ) + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + if BlacklistUserService.is_user_blacklisted(after.id): + return + + if before.premium_since is None and after.premium_since is not None: + await self.on_nitro_boost(after) + + @staticmethod + async def on_nitro_boost(member: discord.Member): + config = GuildConfig(member.guild.id) + + if not config.boost_channel_id: + return + + embed = create_boost_embed( + user_name=member.name, + user_avatar_url=member.display_avatar.url, + boost_count=member.guild.premium_subscription_count, + template=config.boost_message, + image_url=config.boost_image_url, + ) + + try: + channel = member.guild.get_channel(config.boost_channel_id) + if isinstance(channel, discord.TextChannel): + await channel.send( + embed=embed, + content=member.mention, + ) + except Exception as e: + logger.warning( + f"Boost message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}", + ) + + @commands.Cog.listener() + async def on_command_completion(self, ctx: commands.Context[commands.Bot]) -> None: + log_msg = f"{ctx.author.name} executed .{ctx.command.qualified_name if ctx.command else 'Unknown'}" + + if ctx.guild is not None: + logger.debug(f"{log_msg} | guild: {ctx.guild.name} ") + else: + logger.debug(f"{log_msg} in DMs") + + @commands.Cog.listener() + async def on_application_command_completion(self, ctx: discord.Interaction) -> None: + log_msg = f"{ctx.user.name} executed /{ctx.command.qualified_name if ctx.command else 'Unknown'}" + + if ctx.guild is not None: + logger.debug(f"{log_msg} | guild: {ctx.guild.name} ") + else: + logger.debug(f"{log_msg} in DMs") + + +async def setup(bot: commands.Bot): + await bot.add_cog(EventHandler(bot)) diff --git a/handlers/event_handler.py b/handlers/event_handler.py deleted file mode 100644 index edce572..0000000 --- a/handlers/event_handler.py +++ /dev/null @@ -1,86 +0,0 @@ -from discord.ext.commands import Cog -from loguru import logger - -from modules.config import c_boost, c_greet -from services.blacklist_service import BlacklistUserService -from services.config_service import GuildConfig - - -class EventHandler(Cog): - def __init__(self, client): - self.client = client - - @Cog.listener() - async def on_member_join(self, member): - if BlacklistUserService.is_user_blacklisted(member.id): - return - - config = GuildConfig(member.guild.id) - - if not config.welcome_channel_id: - return - - embed = c_greet.create_greet_embed(member, config.welcome_message) - - try: - await member.guild.get_channel(config.welcome_channel_id).send( - embed=embed, - content=member.mention, - ) - except Exception as e: - logger.warning( - f"Greet message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}", - ) - - @Cog.listener() - async def on_member_update(self, before, after): - if BlacklistUserService.is_user_blacklisted(after.id): - return - - if before.premium_since is None and after.premium_since is not None: - await self.on_nitro_boost(after) - - @staticmethod - async def on_nitro_boost(member): - config = GuildConfig(member.guild.id) - - if not config.boost_channel_id: - return - - embed = c_boost.create_boost_embed( - member, - config.boost_message, - config.boost_image_url, - ) - - try: - await member.guild.get_channel(config.boost_channel_id).send( - embed=embed, - content=member.mention, - ) - except Exception as e: - logger.warning( - f"Boost message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}", - ) - - @Cog.listener() - async def on_command_completion(self, ctx) -> None: - log_msg = f"{ctx.author.name} executed .{ctx.command.qualified_name}" - - if ctx.guild is not None: - logger.debug(f"{log_msg} | guild: {ctx.guild.name} ") - else: - logger.debug(f"{log_msg} in DMs") - - @Cog.listener() - async def on_application_command_completion(self, ctx) -> None: - log_msg = f"{ctx.author.name} executed /{ctx.command.qualified_name}" - - if ctx.guild is not None: - logger.debug(f"{log_msg} | guild: {ctx.guild.name} ") - else: - logger.debug(f"{log_msg} in DMs") - - -def setup(client): - client.add_cog(EventHandler(client)) diff --git a/handlers/reaction_handler.py b/handlers/trigger.py similarity index 77% rename from handlers/reaction_handler.py rename to handlers/trigger.py index 177efe8..c5369db 100644 --- a/handlers/reaction_handler.py +++ b/handlers/trigger.py @@ -1,9 +1,11 @@ import contextlib +from typing import Any from discord import Message -from discord.ext.commands import Cog +from discord.ext import commands from loguru import logger +from lib.client import Luminara from services.blacklist_service import BlacklistUserService from services.reactions_service import CustomReactionsService @@ -13,8 +15,8 @@ class ReactionHandler: Handles reactions to messages based on predefined triggers and responses. """ - def __init__(self, client, message: Message) -> None: - self.client = client + def __init__(self, bot: Luminara, message: Message) -> None: + self.bot = bot self.message: Message = message self.content: str = self.message.content.lower() self.reaction_service = CustomReactionsService() @@ -43,7 +45,7 @@ class ReactionHandler: int(data["id"]), ) - async def try_respond(self, data) -> bool: + async def try_respond(self, data: dict[str, Any]) -> bool: """ Tries to respond to the message. """ @@ -53,23 +55,23 @@ class ReactionHandler: return True return False - async def try_react(self, data) -> bool: + async def try_react(self, data: dict[str, Any]) -> bool: """ Tries to react to the message. """ if emoji_id := data.get("emoji_id"): with contextlib.suppress(Exception): - if emoji := self.client.get_emoji(emoji_id): + if emoji := self.bot.get_emoji(emoji_id): await self.message.add_reaction(emoji) return True return False -class ReactionListener(Cog): - def __init__(self, client) -> None: - self.client = client +class ReactionListener(commands.Cog): + def __init__(self, bot: Luminara) -> None: + self.bot = bot - @Cog.listener("on_message") + @commands.Cog.listener("on_message") async def reaction_listener(self, message: Message) -> None: """ Listens for new messages and processes them if the author is not a bot and not blacklisted. @@ -79,8 +81,8 @@ class ReactionListener(Cog): if not message.author.bot and not BlacklistUserService.is_user_blacklisted( message.author.id, ): - await ReactionHandler(self.client, message).run_checks() + await ReactionHandler(self.bot, message).run_checks() -def setup(client) -> None: - client.add_cog(ReactionListener(client)) +async def setup(bot: Luminara) -> None: + await bot.add_cog(ReactionListener(bot)) diff --git a/handlers/xp_handler.py b/handlers/xp.py similarity index 80% rename from handlers/xp_handler.py rename to handlers/xp.py index c36e5b5..7fcd3b5 100644 --- a/handlers/xp_handler.py +++ b/handlers/xp.py @@ -2,27 +2,26 @@ import asyncio import contextlib import random import time -from typing import Optional import discord from discord.ext import commands -from discord.ext.commands import TextChannelConverter +from loguru import logger -from Client import LumiBot -from lib import formatter -from lib.constants import CONST +import lib.format +from lib.client import Luminara +from lib.const import CONST from services.blacklist_service import BlacklistUserService from services.config_service import GuildConfig from services.xp_service import XpRewardService, XpService class XPHandler: - def __init__(self, client: LumiBot, message: discord.Message) -> None: + def __init__(self, client: Luminara, message: discord.Message) -> None: """ Initializes the XPHandler with the given client and message. Args: - client (LumiBot): The bot client. + client (Luminara): The bot client. message (discord.Message): The message object. """ self.client = client @@ -33,7 +32,7 @@ class XPHandler: self.author.id, self.guild.id if self.guild else 0, ) - self.guild_conf: Optional[GuildConfig] = None + self.guild_conf: GuildConfig | None = None def process(self) -> bool: """ @@ -72,13 +71,13 @@ class XPHandler: _xp: XpService = self.xp_conf _gd: GuildConfig = GuildConfig(self.guild.id) - level_message: Optional[str] = None # Initialize level_message + level_message: str | None = None # Initialize level_message if isinstance(self.author, discord.Member): level_message = await self.get_level_message(_gd, _xp, self.author) if level_message: - level_channel: Optional[discord.TextChannel] = await self.get_level_channel( + level_channel: discord.TextChannel | None = await self.get_level_channel( self.message, _gd, ) @@ -102,29 +101,29 @@ class XPHandler: reason: str = "Automated Level Reward" if role := self.guild.get_role(role_id): - with contextlib.suppress( - discord.Forbidden, - discord.NotFound, - discord.HTTPException, - ): + try: if isinstance(self.author, discord.Member): await self.author.add_roles(role, reason=reason) + except (discord.Forbidden, discord.NotFound, discord.HTTPException) as e: + logger.error(f"Failed to add role {role_id} to {self.author.id}: {e}") + previous, replace = _rew.should_replace_previous_reward(_xp.level) - if replace and isinstance(self.author, discord.Member): - if role := self.guild.get_role(previous or role_id): - with contextlib.suppress( - discord.Forbidden, - discord.NotFound, - discord.HTTPException, - ): - await self.author.remove_roles(role, reason=reason) + if ( + replace + and isinstance(self.author, discord.Member) + and (role := self.guild.get_role(previous or role_id)) + ): + try: + await self.author.remove_roles(role, reason=reason) + except (discord.Forbidden, discord.NotFound, discord.HTTPException) as e: + logger.error(f"Failed to replace role {previous} with {role_id} from {self.author.id}: {e}") async def get_level_channel( self, message: discord.Message, guild_config: GuildConfig, - ) -> Optional[discord.TextChannel]: + ) -> discord.TextChannel | None: """ Retrieves the level up notification channel for the guild. @@ -139,7 +138,7 @@ class XPHandler: context = await self.client.get_context(message) with contextlib.suppress(commands.BadArgument, commands.CommandError): - return await TextChannelConverter().convert( + return await commands.TextChannelConverter().convert( context, str(guild_config.level_channel_id), ) @@ -150,7 +149,7 @@ class XPHandler: guild_config: GuildConfig, level_config: XpService, author: discord.Member, - ) -> Optional[str]: + ) -> str | None: """ Retrieves the level up message for the user. @@ -174,13 +173,14 @@ class XPHandler: author, ) else: - level_message = formatter.template( + level_message = lib.format.template( guild_config.level_message, author.name, level_config.level, ) case _: - raise ValueError("Invalid level message type") + msg = "Invalid level message type" + raise ValueError(msg) return level_message @@ -210,8 +210,8 @@ class XPHandler: Returns: str: The whimsical level up message. """ - level_range: Optional[str] = None - for key in CONST.LEVEL_MESSAGES.keys(): + level_range: str | None = None + for key in CONST.LEVEL_MESSAGES: start, end = map(int, key.split("-")) if start <= level <= end: level_range = key @@ -228,14 +228,14 @@ class XPHandler: class XpListener(commands.Cog): - def __init__(self, client: LumiBot) -> None: + def __init__(self, client: Luminara) -> None: """ Initializes the XpListener with the given client. Args: - client (LumiBot): The bot client. + client (Luminara): The bot client. """ - self.client: LumiBot = client + self.client: Luminara = client @commands.Cog.listener("on_message") async def xp_listener(self, message: discord.Message) -> None: @@ -259,5 +259,5 @@ class XpListener(commands.Cog): ) -def setup(client: LumiBot) -> None: - client.add_cog(XpListener(client)) +async def setup(client: Luminara) -> None: + await client.add_cog(XpListener(client)) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/moderation/utils/actionable.py b/lib/actionable.py similarity index 91% rename from modules/moderation/utils/actionable.py rename to lib/actionable.py index 5b0d811..2a563b2 100644 --- a/modules/moderation/utils/actionable.py +++ b/lib/actionable.py @@ -1,7 +1,7 @@ import discord -from lib.constants import CONST -from lib.exceptions.LumiExceptions import LumiException +from lib.const import CONST +from lib.exceptions import LumiException async def async_actionable( diff --git a/modules/moderation/utils/case_handler.py b/lib/case_handler.py similarity index 84% rename from modules/moderation/utils/case_handler.py rename to lib/case_handler.py index 024e040..4f39b54 100644 --- a/modules/moderation/utils/case_handler.py +++ b/lib/case_handler.py @@ -1,24 +1,23 @@ -from typing import Optional - import discord -from discord.ext.commands import TextChannelConverter, UserConverter +from discord.ext import commands from loguru import logger -from modules.moderation.utils.case_embed import create_case_embed -from services.moderation.case_service import CaseService -from services.moderation.modlog_service import ModLogService +from lib.exceptions import LumiException +from services.case_service import CaseService +from services.modlog_service import ModLogService +from ui.cases import create_case_embed case_service = CaseService() modlog_service = ModLogService() async def create_case( - ctx, + ctx: commands.Context[commands.Bot], target: discord.User, action_type: str, - reason: Optional[str] = None, - duration: Optional[int] = None, - expires_at: Optional[str] = None, + reason: str | None = None, + duration: int | None = None, + expires_at: str | None = None, ): """ Creates a new moderation case and logs it to the modlog channel if configured. @@ -43,6 +42,10 @@ async def create_case( 3. If a modlog channel is configured, it sends an embed with the case details to that channel. 4. If the embed is successfully sent to the modlog channel, it updates the case with the message ID for later edits. """ + + if not ctx.guild: + raise LumiException + guild_id = ctx.guild.id moderator_id = ctx.author.id target_id = target.id @@ -63,7 +66,7 @@ async def create_case( if mod_log_channel_id := modlog_service.fetch_modlog_channel_id(guild_id): try: - mod_log_channel = await TextChannelConverter().convert( + mod_log_channel = await commands.TextChannelConverter().convert( ctx, str(mod_log_channel_id), ) @@ -90,7 +93,7 @@ async def create_case( async def edit_case_modlog( - ctx, + ctx: commands.Context[commands.Bot], guild_id: int, case_number: int, new_reason: str, @@ -110,7 +113,8 @@ async def edit_case_modlog( """ case = case_service.fetch_case_by_guild_and_number(guild_id, case_number) if not case: - raise ValueError(f"Case {case_number} not found in guild {guild_id}") + msg = f"Case {case_number} not found in guild {guild_id}" + raise ValueError(msg) modlog_message_id = case.get("modlog_message_id") if not modlog_message_id: @@ -121,12 +125,12 @@ async def edit_case_modlog( return False try: - mod_log_channel = await TextChannelConverter().convert( + mod_log_channel = await commands.TextChannelConverter().convert( ctx, str(mod_log_channel_id), ) message = await mod_log_channel.fetch_message(modlog_message_id) - target = await UserConverter().convert(ctx, str(case["target_id"])) + target = await commands.UserConverter().convert(ctx, str(case["target_id"])) updated_embed: discord.Embed = create_case_embed( ctx=ctx, diff --git a/lib/checks.py b/lib/checks.py index c38e1de..9443b10 100644 --- a/lib/checks.py +++ b/lib/checks.py @@ -1,19 +1,19 @@ -from discord.ext import commands +import discord +from discord import app_commands -from lib.exceptions import LumiExceptions +from lib.exceptions import BirthdaysDisabled from services.config_service import GuildConfig def birthdays_enabled(): - async def predicate(ctx): - if ctx.guild is None: + async def predicate(interaction: discord.Interaction) -> bool: + if interaction.guild is None: return True - guild_config = GuildConfig(ctx.guild.id) - - if not guild_config.birthday_channel_id: - raise LumiExceptions.BirthdaysDisabled + guild_config = GuildConfig(interaction.guild.id) + if guild_config.birthday_channel_id is None: + raise BirthdaysDisabled return True - return commands.check(predicate) + return app_commands.check(predicate) diff --git a/lib/client.py b/lib/client.py new file mode 100644 index 0000000..f2b4895 --- /dev/null +++ b/lib/client.py @@ -0,0 +1,70 @@ +import asyncio +import os +import platform +from typing import Any + +import discord +from discord.ext import commands +from loguru import logger + +from db.database import run_migrations +from lib.const import CONST +from lib.loader import CogLoader + + +class Luminara(commands.Bot): + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.is_shutting_down: bool = False + self.setup_task: asyncio.Task[None] = asyncio.create_task(self.setup()) + + async def on_ready(self) -> None: + logger.success(f"{CONST.TITLE} v{CONST.VERSION}") + logger.success(f"Logged in with ID {self.user.id if self.user else 'Unknown'}") + logger.success(f"discord.py API version: {discord.__version__}") + logger.success(f"Python version: {platform.python_version()}") + logger.success(f"Running on: {platform.system()} {platform.release()} ({os.name})") + + if self.owner_ids: + for owner in self.owner_ids: + logger.info(f"Added bot administrator: {owner}") + + if not self.setup_task.done(): + await self.setup_task + + async def setup(self) -> None: + try: + run_migrations() + except Exception as e: + logger.error(f"Failed to setup: {e}") + await self.shutdown() + + await self.load_cogs() + + async def load_cogs(self) -> None: + await CogLoader.setup(bot=self) + + @commands.Cog.listener() + async def on_disconnect(self) -> None: + logger.warning("Disconnected from Discord.") + + async def shutdown(self) -> None: + if self.is_shutting_down: + logger.info("Shutdown already in progress. Exiting.") + return + + self.is_shutting_down = True + logger.info("Shutting down...") + + await self.close() + + if tasks := [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]: + logger.debug(f"Cancelling {len(tasks)} outstanding tasks.") + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.debug("All tasks cancelled.") + + logger.info("Shutdown complete.") diff --git a/lib/const.py b/lib/const.py new file mode 100644 index 0000000..58d5d2f --- /dev/null +++ b/lib/const.py @@ -0,0 +1,127 @@ +import json +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any, Final + +import yaml + + +class Parser: + """Internal parses class. Not intended to be used outside of this module.""" + + def __init__(self): + self._cache: dict[str, Any] = {} + + def read_s(self) -> dict[str, Any]: + if "settings" not in self._cache: + self._cache["settings"] = self._read_file("settings.yaml", yaml.safe_load) + return self._cache["settings"] + + def read_json(self, path: str) -> dict[str, Any]: + cache_key = f"json_{path}" + if cache_key not in self._cache: + self._cache[cache_key] = self._read_file(f"locales/{path}.json", json.load) + return self._cache[cache_key] + + def _read_file(self, file_path: str, load_func: Callable[[Any], dict[str, Any]]) -> dict[str, Any]: + with Path(file_path).open() as file: + return load_func(file) + + +class Constants: + _p: Final = Parser() + _s: Final = Parser().read_s() + + # bot credentials + TOKEN: Final[str | None] = os.environ.get("TOKEN") + INSTANCE: Final[str | None] = os.environ.get("INSTANCE") + OWNER_IDS: Final[set[int]] = {int(oid) for oid in os.environ.get("OWNER_IDS", "").split(",") if oid.strip()} + XP_GAIN_PER_MESSAGE: Final[int] = int(os.environ.get("XP_GAIN_PER_MESSAGE", 1)) + XP_GAIN_COOLDOWN: Final[int] = int(os.environ.get("XP_GAIN_COOLDOWN", 8)) + DBX_TOKEN: Final[str | None] = os.environ.get("DBX_OAUTH2_REFRESH_TOKEN") + DBX_APP_KEY: Final[str | None] = os.environ.get("DBX_APP_KEY") + DBX_APP_SECRET: Final[str | None] = os.environ.get("DBX_APP_SECRET") + MARIADB_USER: Final[str | None] = os.environ.get("MARIADB_USER") + MARIADB_PASSWORD: Final[str | None] = os.environ.get("MARIADB_PASSWORD") + MARIADB_ROOT_PASSWORD: Final[str | None] = os.environ.get("MARIADB_ROOT_PASSWORD") + MARIADB_DATABASE: Final[str | None] = os.environ.get("MARIADB_DATABASE") + + # metadata + TITLE: Final[str] = _s["info"]["title"] + AUTHOR: Final[str] = _s["info"]["author"] + LICENSE: Final[str] = _s["info"]["license"] + VERSION: Final[str] = _s["info"]["version"] + REPO_URL: Final[str] = _s["info"]["repository_url"] + INVITE_URL: Final[str] = _s["info"]["invite_url"] + + # loguru + LOG_LEVEL: Final[str] = _s["logs"]["level"] or "DEBUG" + LOG_FORMAT: Final[str] = _s["logs"]["format"] + + # cogs + COG_IGNORE_LIST: Final[set[str]] = set(_s["cogs"]["ignore"]) if _s["cogs"]["ignore"] else set() + + # images + ALLOWED_IMAGE_EXTENSIONS: Final[list[str]] = _s["images"]["allowed_image_extensions"] + BIRTHDAY_GIF_URL: Final[str] = _s["images"]["birthday_gif_url"] + + # colors + COLOR_DEFAULT: Final[int] = _s["colors"]["color_default"] + COLOR_WARNING: Final[int] = _s["colors"]["color_warning"] + COLOR_ERROR: Final[int] = _s["colors"]["color_error"] + + # economy + DAILY_REWARD: Final[int] = _s["economy"]["daily_reward"] + BLACKJACK_MULTIPLIER: Final[float] = _s["economy"]["blackjack_multiplier"] + BLACKJACK_HIT_EMOJI: Final[str] = _s["economy"]["blackjack_hit_emoji"] + BLACKJACK_STAND_EMOJI: Final[str] = _s["economy"]["blackjack_stand_emoji"] + SLOTS_MULTIPLIERS: Final[dict[str, float]] = _s["economy"]["slots_multipliers"] + + # art from git repository + _fetch_url: Final[str] = _s["art"]["fetch_url"] + + LUMI_LOGO_OPAQUE: Final[str] = _fetch_url + _s["art"]["logo"]["opaque"] + LUMI_LOGO_TRANSPARENT: Final[str] = _fetch_url + _s["art"]["logo"]["transparent"] + BOOST_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["boost"] + CHECK_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["check"] + CROSS_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["cross"] + EXCLAIM_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["exclaim"] + INFO_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["info"] + HAMMER_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["hammer"] + MONEY_BAG_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["money_bag"] + MONEY_COINS_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["money_coins"] + QUESTION_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["question"] + STREAK_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak"] + STREAK_BRONZE_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_bronze"] + STREAK_GOLD_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_gold"] + STREAK_SILVER_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_silver"] + WARNING_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["warning"] + + # art from imgur + FLOWERS_ART: Final[str] = _s["art"]["juicybblue"]["flowers"] + TEAPOT_ART: Final[str] = _s["art"]["juicybblue"]["teapot"] + MUFFIN_ART: Final[str] = _s["art"]["juicybblue"]["muffin"] + CLOUD_ART: Final[str] = _s["art"]["other"]["cloud"] + TROPHY_ART: Final[str] = _s["art"]["other"]["trophy"] + + # emotes + EMOTES_SERVER_ID: Final[int] = _s["emotes"]["guild_id"] + EMOTE_IDS: Final[dict[str, int]] = _s["emotes"]["emote_ids"] + + # introductions (currently only usable in ONE guild) + INTRODUCTIONS_GUILD_ID: Final[int] = _s["introductions"]["intro_guild_id"] + INTRODUCTIONS_CHANNEL_ID: Final[int] = _s["introductions"]["intro_channel_id"] + INTRODUCTIONS_QUESTION_MAPPING: Final[dict[str, str]] = _s["introductions"]["intro_question_mapping"] + + # Reponse strings + # TODO: Implement switching between languages + STRINGS: Final = _p.read_json("strings.en-US") + LEVEL_MESSAGES: Final = _p.read_json("levels.en-US") + + _bday: Final = _p.read_json("bdays.en-US") + BIRTHDAY_MESSAGES: Final[list[str]] = _bday["birthday_messages"] + BIRTHDAY_MONTHS: Final[list[str]] = _bday["months"] + + +CONST = Constants() diff --git a/lib/constants.py b/lib/constants.py deleted file mode 100644 index 9e48593..0000000 --- a/lib/constants.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -from typing import Optional, Set, List, Dict -import yaml -import json -from functools import lru_cache - - -class _parser: - """Internal parser class. Not intended for direct use outside this module.""" - - @lru_cache(maxsize=1024) - def read_yaml(self, path): - return self._read_file(f"settings/{path}.yaml", yaml.safe_load) - - @lru_cache(maxsize=1024) - def read_json(self, path): - return self._read_file(f"settings/{path}.json", json.load) - - def _read_file(self, file_path, load_func): - with open(file_path) as file: - return load_func(file) - - -class Constants: - _p = _parser() - _settings = _p.read_yaml("settings") - - # bot credentials (.env file) - TOKEN: Optional[str] = os.environ.get("TOKEN") - INSTANCE: Optional[str] = os.environ.get("INSTANCE") - XP_GAIN_PER_MESSAGE: int = int(os.environ.get("XP_GAIN_PER_MESSAGE", 1)) - XP_GAIN_COOLDOWN: int = int(os.environ.get("XP_GAIN_COOLDOWN", 8)) - DBX_TOKEN: Optional[str] = os.environ.get("DBX_OAUTH2_REFRESH_TOKEN") - DBX_APP_KEY: Optional[str] = os.environ.get("DBX_APP_KEY") - DBX_APP_SECRET: Optional[str] = os.environ.get("DBX_APP_SECRET") - MARIADB_USER: Optional[str] = os.environ.get("MARIADB_USER") - MARIADB_PASSWORD: Optional[str] = os.environ.get("MARIADB_PASSWORD") - MARIADB_ROOT_PASSWORD: Optional[str] = os.environ.get("MARIADB_ROOT_PASSWORD") - MARIADB_DATABASE: Optional[str] = os.environ.get("MARIADB_DATABASE") - - OWNER_IDS: Optional[Set[int]] = ( - {int(id.strip()) for id in os.environ.get("OWNER_IDS", "").split(",") if id} - if "OWNER_IDS" in os.environ - else None - ) - - # metadata - TITLE: str = _settings["info"]["title"] - AUTHOR: str = _settings["info"]["author"] - LICENSE: str = _settings["info"]["license"] - VERSION: str = _settings["info"]["version"] - REPO_URL: str = _settings["info"]["repository_url"] - - # images - ALLOWED_IMAGE_EXTENSIONS: List[str] = _settings["images"][ - "allowed_image_extensions" - ] - BIRTHDAY_GIF_URL: str = _settings["images"]["birthday_gif_url"] - - # colors - COLOR_DEFAULT: int = _settings["colors"]["color_default"] - COLOR_WARNING: int = _settings["colors"]["color_warning"] - COLOR_ERROR: int = _settings["colors"]["color_error"] - - # economy - DAILY_REWARD: int = _settings["economy"]["daily_reward"] - BLACKJACK_MULTIPLIER: float = _settings["economy"]["blackjack_multiplier"] - BLACKJACK_HIT_EMOJI: str = _settings["economy"]["blackjack_hit_emoji"] - BLACKJACK_STAND_EMOJI: str = _settings["economy"]["blackjack_stand_emoji"] - SLOTS_MULTIPLIERS: Dict[str, float] = _settings["economy"]["slots_multipliers"] - - # art from git repository - _fetch_url: str = _settings["art"]["fetch_url"] - - LUMI_LOGO_OPAQUE: str = _fetch_url + _settings["art"]["logo"]["opaque"] - LUMI_LOGO_TRANSPARENT: str = _fetch_url + _settings["art"]["logo"]["transparent"] - BOOST_ICON: str = _fetch_url + _settings["art"]["icons"]["boost"] - CHECK_ICON: str = _fetch_url + _settings["art"]["icons"]["check"] - CROSS_ICON: str = _fetch_url + _settings["art"]["icons"]["cross"] - EXCLAIM_ICON: str = _fetch_url + _settings["art"]["icons"]["exclaim"] - HAMMER_ICON: str = _fetch_url + _settings["art"]["icons"]["hammer"] - MONEY_BAG_ICON: str = _fetch_url + _settings["art"]["icons"]["money_bag"] - MONEY_COINS_ICON: str = _fetch_url + _settings["art"]["icons"]["money_coins"] - QUESTION_ICON: str = _fetch_url + _settings["art"]["icons"]["question"] - STREAK_ICON: str = _fetch_url + _settings["art"]["icons"]["streak"] - STREAK_BRONZE_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_bronze"] - STREAK_GOLD_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_gold"] - STREAK_SILVER_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_silver"] - WARNING_ICON: str = _fetch_url + _settings["art"]["icons"]["warning"] - - # art from imgur - FLOWERS_ART: str = _settings["art"]["juicybblue"]["flowers"] - TEAPOT_ART: str = _settings["art"]["juicybblue"]["teapot"] - MUFFIN_ART: str = _settings["art"]["juicybblue"]["muffin"] - CLOUD_ART: str = _settings["art"]["other"]["cloud"] - TROPHY_ART: str = _settings["art"]["other"]["trophy"] - - # emotes - EMOTES_SERVER_ID: int = _settings["emotes"]["guild_id"] - EMOTE_IDS: Dict[str, int] = _settings["emotes"]["emote_ids"] - - # introductions (currently only usable in ONE guild) - INTRODUCTIONS_GUILD_ID: int = _settings["introductions"]["intro_guild_id"] - INTRODUCTIONS_CHANNEL_ID: int = _settings["introductions"]["intro_channel_id"] - INTRODUCTIONS_QUESTION_MAPPING: Dict[str, str] = _settings["introductions"][ - "intro_question_mapping" - ] - - # Response strings - # TODO: Implement switching between languages - STRINGS = _p.read_json("responses/strings.en-US") - LEVEL_MESSAGES = _p.read_json("responses/levels.en-US") - - # birthday messages - _bday = _p.read_json("responses/bdays.en-US") - BIRTHDAY_MESSAGES = _bday["birthday_messages"] - BIRTHDAY_MONTHS = _bday["months"] - - -CONST = Constants() diff --git a/lib/embed_builder.py b/lib/embed_builder.py deleted file mode 100644 index 1470daa..0000000 --- a/lib/embed_builder.py +++ /dev/null @@ -1,206 +0,0 @@ -import datetime - -import discord - -from lib.constants import CONST - - -class EmbedBuilder: - @staticmethod - def create_embed( - ctx, - title=None, - author_text=None, - author_icon_url=None, - author_url=None, - description=None, - color=None, - footer_text=None, - footer_icon_url=None, - show_name=True, - image_url=None, - thumbnail_url=None, - timestamp=None, - hide_author=False, - hide_author_icon=False, - hide_timestamp=False, - ): - if not hide_author: - if not author_text: - author_text = ctx.author.name - elif show_name: - description = f"**{ctx.author.name}** {description}" - - if not hide_author_icon and not author_icon_url: - author_icon_url = ctx.author.display_avatar.url - - if not footer_text: - footer_text = "Luminara" - if not footer_icon_url: - footer_icon_url = CONST.LUMI_LOGO_TRANSPARENT - - embed = discord.Embed( - title=title, - description=description, - color=color or CONST.COLOR_DEFAULT, - ) - if not hide_author: - embed.set_author( - name=author_text, - icon_url=None if hide_author_icon else author_icon_url, - url=author_url, - ) - embed.set_footer(text=footer_text, icon_url=footer_icon_url) - if not hide_timestamp: - embed.timestamp = timestamp or datetime.datetime.now() - - if image_url: - embed.set_image(url=image_url) - if thumbnail_url: - embed.set_thumbnail(url=thumbnail_url) - - return embed - - @staticmethod - def create_error_embed( - ctx, - title=None, - author_text=None, - author_icon_url=None, - author_url=None, - description=None, - footer_text=None, - show_name=True, - image_url=None, - thumbnail_url=None, - timestamp=None, - hide_author=False, - hide_author_icon=False, - hide_timestamp=False, - ): - return EmbedBuilder.create_embed( - ctx, - title=title, - author_text=author_text, - author_icon_url=author_icon_url or CONST.CROSS_ICON, - author_url=author_url, - description=description, - color=CONST.COLOR_ERROR, - footer_text=footer_text, - footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT, - show_name=show_name, - image_url=image_url, - thumbnail_url=thumbnail_url, - timestamp=timestamp, - hide_author=hide_author, - hide_author_icon=hide_author_icon, - hide_timestamp=hide_timestamp, - ) - - @staticmethod - def create_success_embed( - ctx, - title=None, - author_text=None, - author_icon_url=None, - author_url=None, - description=None, - footer_text=None, - show_name=True, - image_url=None, - thumbnail_url=None, - timestamp=None, - hide_author=False, - hide_author_icon=False, - hide_timestamp=False, - ): - return EmbedBuilder.create_embed( - ctx, - title=title, - author_text=author_text, - author_icon_url=author_icon_url or CONST.CHECK_ICON, - author_url=author_url, - description=description, - color=CONST.COLOR_DEFAULT, - footer_text=footer_text, - footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT, - show_name=show_name, - image_url=image_url, - thumbnail_url=thumbnail_url, - timestamp=timestamp, - hide_author=hide_author, - hide_author_icon=hide_author_icon, - hide_timestamp=hide_timestamp, - ) - - @staticmethod - def create_info_embed( - ctx, - title=None, - author_text=None, - author_icon_url=None, - author_url=None, - description=None, - footer_text=None, - show_name=True, - image_url=None, - thumbnail_url=None, - timestamp=None, - hide_author=False, - hide_author_icon=False, - hide_timestamp=False, - ): - return EmbedBuilder.create_embed( - ctx, - title=title, - author_text=author_text, - author_icon_url=author_icon_url or CONST.EXCLAIM_ICON, - author_url=author_url, - description=description, - color=CONST.COLOR_DEFAULT, - footer_text=footer_text, - footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT, - show_name=show_name, - image_url=image_url, - thumbnail_url=thumbnail_url, - timestamp=timestamp, - hide_author=hide_author, - hide_author_icon=hide_author_icon, - hide_timestamp=hide_timestamp, - ) - - @staticmethod - def create_warning_embed( - ctx, - title=None, - author_text=None, - author_icon_url=None, - author_url=None, - description=None, - footer_text=None, - show_name=True, - image_url=None, - thumbnail_url=None, - timestamp=None, - hide_author=False, - hide_author_icon=False, - hide_timestamp=False, - ): - return EmbedBuilder.create_embed( - ctx, - title=title, - author_text=author_text, - author_icon_url=author_icon_url or CONST.WARNING_ICON, - author_url=author_url, - description=description, - color=CONST.COLOR_WARNING, - footer_text=footer_text, - footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT, - show_name=show_name, - image_url=image_url, - thumbnail_url=thumbnail_url, - timestamp=timestamp, - hide_author=hide_author, - hide_author_icon=hide_author_icon, - hide_timestamp=hide_timestamp, - ) diff --git a/lib/exceptions.py b/lib/exceptions.py new file mode 100644 index 0000000..4ffe9df --- /dev/null +++ b/lib/exceptions.py @@ -0,0 +1,36 @@ +from discord import app_commands +from discord.ext import commands + +from lib.const import CONST + + +class BirthdaysDisabled(commands.CheckFailure, app_commands.CheckFailure): + """ + Raised when the birthdays module is disabled in ctx.guild. + """ + + +class LumiException(commands.CommandError, app_commands.AppCommandError): + """ + A generic exception to raise for quick error handling. + """ + + def __init__(self, message: str = CONST.STRINGS["lumi_exception_generic"]): + self.message = message + super().__init__(message) + + def __str__(self) -> str: + return self.message + + +class Blacklisted(commands.CommandError, app_commands.AppCommandError): + """ + Raised when a user is blacklisted. + """ + + def __init__(self, message: str = CONST.STRINGS["lumi_exception_blacklisted"]): + self.message = message + super().__init__(message) + + def __str__(self) -> str: + return self.message diff --git a/lib/exceptions/LumiExceptions.py b/lib/exceptions/LumiExceptions.py deleted file mode 100644 index 0136e31..0000000 --- a/lib/exceptions/LumiExceptions.py +++ /dev/null @@ -1,31 +0,0 @@ -from discord.ext import commands - -from lib.constants import CONST - - -class BirthdaysDisabled(commands.CheckFailure): - """ - Raised when the birthdays module is disabled in ctx.guild. - """ - - pass - - -class LumiException(commands.CommandError): - """ - A generic exception to raise for quick error handling. - """ - - def __init__(self, message=CONST.STRINGS["lumi_exception_generic"]): - self.message = message - super().__init__(message) - - -class Blacklisted(commands.CommandError): - """ - Raised when a user is blacklisted. - """ - - def __init__(self, message=CONST.STRINGS["lumi_exception_blacklisted"]): - self.message = message - super().__init__(message) diff --git a/lib/formatter.py b/lib/format.py similarity index 56% rename from lib/formatter.py rename to lib/format.py index 019613b..7d7f777 100644 --- a/lib/formatter.py +++ b/lib/format.py @@ -1,11 +1,13 @@ +import inspect import textwrap +from typing import Any import discord from discord.ext import commands -from pytimeparse import parse +from pytimeparse import parse # type: ignore -from lib.constants import CONST -from lib.exceptions.LumiExceptions import LumiException +from lib import exceptions +from lib.const import CONST from services.config_service import GuildConfig @@ -74,7 +76,7 @@ def format_case_number(case_number: int) -> str: return f"{case_number:03d}" if case_number < 1000 else str(case_number) -def get_prefix(ctx: commands.Context) -> str: +def get_prefix(ctx: commands.Context[commands.Bot]) -> str: """ Attempts to retrieve the prefix for the given guild context. @@ -90,7 +92,7 @@ def get_prefix(ctx: commands.Context) -> str: return "." -def get_invoked_name(ctx: commands.Context) -> str | None: +def get_invoked_name(ctx: commands.Context[commands.Bot]) -> str | None: """ Attempts to get the alias of the command used. If the user used a SlashCommand, return the command name. @@ -102,20 +104,24 @@ def get_invoked_name(ctx: commands.Context) -> str | None: """ try: return ctx.invoked_with - except (discord.ApplicationCommandInvokeError, AttributeError): + + except (discord.app_commands.CommandInvokeError, AttributeError): return ctx.command.name if ctx.command else None def format_duration_to_seconds(duration: str) -> int: """ - Formats a duration in seconds to a human-readable string. + Converts a duration string to seconds. If the input is just an integer, it returns that integer as seconds. """ - parsed_duration = parse(duration) + if duration.isdigit(): + return int(duration) - if isinstance(parsed_duration, int): - return parsed_duration - else: - raise LumiException(CONST.STRINGS["error_invalid_duration"].format(duration)) + try: + parsed_duration: int = parse(duration) # type: ignore + return max(0, parsed_duration) + + except Exception as e: + raise exceptions.LumiException(CONST.STRINGS["error_invalid_duration"].format(duration)) from e def format_seconds_to_duration_string(seconds: int) -> str: @@ -132,7 +138,61 @@ def format_seconds_to_duration_string(seconds: int) -> str: if days > 0: return f"{days}d{hours}h" if hours > 0 else f"{days}d" - elif hours > 0: + if hours > 0: return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h" - else: - return f"{minutes}m" + + return f"{minutes}m" + + +def generate_usage( + command: commands.Command[Any, Any, Any], + flag_converter: type[commands.FlagConverter] | None = None, +) -> str: + """ + Generate a usage string for a command with flags. + Credit to https://github.com/allthingslinux/tux (thanks kaizen ;p) + + Parameters + ---------- + command : commands.Command + The command for which to generate the usage string. + flag_converter : type[commands.FlagConverter] + The flag converter class for the command. + + Returns + ------- + str + The usage string for the command. Example: "ban [target] -[reason] -" + """ + + # Get the name of the command + command_name = command.qualified_name + + # Start the usage string with the command name + usage = f"{command_name}" + + # Get the parameters of the command (excluding the `ctx` and `flags` parameters) + parameters: dict[str, commands.Parameter] = command.clean_params + + flag_prefix = getattr(flag_converter, "__commands_flag_prefix__", "-") + flags: dict[str, commands.Flag] = flag_converter.get_flags() if flag_converter else {} + + # Add non-flag arguments to the usage string + for param_name, param in parameters.items(): + # Ignore these parameters + if param_name in ["ctx", "flags"]: + continue + # Determine if the parameter is required + is_required = param.default == inspect.Parameter.empty + # Add the parameter to the usage string with required or optional wrapping + usage += f" <{param_name}>" if is_required else f" [{param_name}]" + + # Add flag arguments to the usage string + for flag_name, flag_obj in flags.items(): + # Determine if the flag is required or optional + if flag_obj.required: + usage += f" {flag_prefix}<{flag_name}>" + else: + usage += f" {flag_prefix}[{flag_name}]" + + return usage diff --git a/lib/help.py b/lib/help.py new file mode 100644 index 0000000..436236f --- /dev/null +++ b/lib/help.py @@ -0,0 +1,180 @@ +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import discord +from discord.ext import commands + +from lib.const import CONST +from ui.embeds import Builder + + +class LuminaraHelp(commands.HelpCommand): + def __init__(self): + """Initializes the LuminaraHelp command with necessary attributes.""" + super().__init__( + command_attrs={ + "help": "Lists all commands and sub-commands.", + "aliases": ["h"], + "usage": "$help or ", + }, + ) + + async def _get_prefix(self) -> str: + """ + Dynamically fetches the prefix from the context or uses a default prefix constant. + + Returns + ------- + str + The prefix used to invoke the bot. + """ + return "." + + def _embed_base(self, author: str, description: str | None = None) -> discord.Embed: + """ + Creates a base embed with uniform styling. + + Parameters + ---------- + title : str + The title of the embed. + description : str | None + The description of the embed. + + Returns + ------- + discord.Embed + The created embed. + """ + return Builder.create_embed( + theme="info", + author_text=author, + description=description, + footer_text=CONST.STRINGS["help_footer"], + ) + + def _get_cog_groups(self) -> list[str]: + """ + Retrieves a list of cog groups from the 'modules' folder. + + Returns + ------- + list[str] + A list of cog groups. + """ + cog_groups = sorted( + [ + d + for d in os.listdir("./modules") + if Path(f"./modules/{d}").is_dir() and d not in ("__pycache__", "admin") + ], + ) + if "moderation" in cog_groups: + cog_groups.remove("moderation") + cog_groups.insert(0, "moderation") + return cog_groups + + async def send_bot_help( + self, + mapping: Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]], + ) -> None: + """ + Sends an overview of all commands in a single embed, grouped by module. + + Parameters + ---------- + mapping : Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]] + The mapping of cogs to commands. + """ + embed = self._embed_base("Luminara Help Overview") + + cog_groups = self._get_cog_groups() + for group in cog_groups: + group_commands: list[commands.Command[Any, Any, Any]] = [] + for cog, commands_list in mapping.items(): + if cog and commands_list and cog.__module__.startswith(f"modules.{group}"): + group_commands.extend(commands_list) + if group_commands: + command_list = ", ".join(f"`{c.name}`" for c in group_commands) + embed.add_field(name=group.capitalize(), value=command_list, inline=False) + + await self.get_destination().send(embed=embed) + + async def _add_command_help_fields(self, embed: discord.Embed, command: commands.Command[Any, Any, Any]) -> None: + """ + Adds fields with usage and alias information for a command to an embed. + + Parameters + ---------- + embed : discord.Embed + The embed to which the fields will be added. + command : commands.Command[Any, Any, Any] + The command whose details are to be added. + """ + prefix = await self._get_prefix() + + embed.add_field( + name="Usage", + value=f"`{prefix}{command.usage or 'No usage.'}`", + inline=False, + ) + + async def send_command_help(self, command: commands.Command[Any, Any, Any]) -> None: + """ + Sends a help message for a specific command. + + Parameters + ---------- + command : commands.Command[Any, Any, Any] + The command for which the help message is to be sent. + """ + prefix = await self._get_prefix() + + author = f"{prefix}{command.qualified_name}" + author += f" ({', '.join(command.aliases)})" if command.aliases else "" + + embed = self._embed_base( + author=author, + description=f"> {command.help}" or "No description available.", + ) + + await self._add_command_help_fields(embed, command) + await self.get_destination().send(embed=embed) + + async def send_group_help(self, group: commands.Group[Any, Any, Any]) -> None: + """ + Sends a help message for a specific command group. + + Parameters + ---------- + group : commands.Group[Any, Any, Any] + The group for which the help message is to be sent. + """ + prefix = await self._get_prefix() + embed = self._embed_base( + author=f"{prefix}{group.qualified_name}", + description=group.help or "No description available.", + ) + + for command in group.commands: + embed.add_field(name=command.name, value=command.short_doc or "No description available.", inline=False) + + await self.get_destination().send(embed=embed) + + async def send_error_message(self, error: str) -> None: + """ + Sends an error message. + + Parameters + ---------- + error : str + The error message to be sent. + """ + embed = Builder.create_embed( + theme="error", + title="Error in help command", + description=error, + ) + await self.get_destination().send(embed=embed, delete_after=30) diff --git a/lib/interaction.py b/lib/interaction.py deleted file mode 100644 index b49443e..0000000 --- a/lib/interaction.py +++ /dev/null @@ -1,34 +0,0 @@ -import discord -from discord.ui import View - - -class ExchangeConfirmation(View): - def __init__(self, ctx): - super().__init__(timeout=180) - self.ctx = ctx - self.clickedConfirm = False - - async def on_timeout(self): - for child in self.children: - child.disabled = True - await self.message.edit(view=None) - - @discord.ui.button(label="Confirm", style=discord.ButtonStyle.green) - async def confirm_button_callback(self, button, interaction): - await interaction.response.edit_message(view=None) - self.clickedConfirm = True - self.stop() - - @discord.ui.button(label="Stop", style=discord.ButtonStyle.red) - async def stop_button_callback(self, button, interaction): - await interaction.response.edit_message(view=None) - self.stop() - - async def interaction_check(self, interaction) -> bool: - if interaction.user == self.ctx.author: - return True - await interaction.response.send_message( - "You can't use these buttons, they're someone else's!", - ephemeral=True, - ) - return False diff --git a/lib/loader.py b/lib/loader.py new file mode 100644 index 0000000..21d7e4f --- /dev/null +++ b/lib/loader.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import aiofiles.os +from discord.ext import commands +from loguru import logger + +from lib.const import CONST + + +class CogLoader(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.cog_ignore_list: set[str] = CONST.COG_IGNORE_LIST + + async def is_cog(self, path: Path) -> bool: + cog_name: str = path.stem + + if cog_name in self.cog_ignore_list: + logger.debug(f"Ignoring cog: {cog_name} because it is in the ignore list") + return False + + return path.suffix == ".py" and not path.name.startswith("_") and await aiofiles.os.path.isfile(path) + + async def load_cogs(self, path: Path) -> None: + try: + if await aiofiles.os.path.isdir(path): + for item in path.iterdir(): + try: + await self.load_cogs(path=item) + except Exception as e: + logger.exception(f"Error loading cog from {item}: {e}") + + elif await self.is_cog(path): + relative_path: Path = path.relative_to(Path(__file__).parent.parent) + module: str = str(relative_path).replace("/", ".").replace("\\", ".")[:-3] + try: + await self.bot.load_extension(name=module) + logger.debug(f"Loaded cog: {module}") + + except Exception as e: + logger.exception(f"Error loading cog: {module}. Error: {e}") + + except Exception as e: + logger.exception(f"Error loading cogs from {path}: {e}") + + async def load_cog_from_dir(self, dir_name: str) -> None: + path: Path = Path(__file__).parent.parent / dir_name + await self.load_cogs(path) + + @classmethod + async def setup(cls, bot: commands.Bot) -> None: + cog_loader = cls(bot) + await cog_loader.load_cog_from_dir(dir_name="modules") + await cog_loader.load_cog_from_dir(dir_name="handlers") + await bot.add_cog(cog_loader) diff --git a/lib/reactions.py b/lib/reactions.py deleted file mode 100644 index 9ccb5b2..0000000 --- a/lib/reactions.py +++ /dev/null @@ -1,36 +0,0 @@ -import random - - -class ReactionHandler: - def __init__(self): - self.eightball = [ - "It is certain.", - "It is decidedly so.", - "Without a doubt.", - "Yes - definitely.", - "You may rely on it.", - "As I see it, yes.", - "Most likely.", - "Outlook good.", - "Yes.", - "Signs point to yes.", - "Reply hazy, try again.", - "Ask again later.", - "Better not tell you now.", - "Cannot predict now.", - "Concentrate and ask again.", - "Don't count on it.", - "My reply is no.", - "My sources say no.", - "Outlook not so good.", - "Very doubtful.", - ] - - async def handle_message(self, message): - content = message.content.lower() - - if ( - content.startswith("Lumi ") or content.startswith("Lumi, ") - ) and content.endswith("?"): - response = random.choice(self.eightball) - await message.reply(content=response) diff --git a/lib/time.py b/lib/time.py deleted file mode 100644 index 9d0b2f5..0000000 --- a/lib/time.py +++ /dev/null @@ -1,19 +0,0 @@ -import datetime - -import pytz - - -def seconds_until(hours, minutes): - eastern_timezone = pytz.timezone("US/Eastern") - - now = datetime.datetime.now(eastern_timezone) - - # Create a datetime object for the given time in the Eastern Timezone - given_time = datetime.time(hours, minutes) - future_exec = eastern_timezone.localize(datetime.datetime.combine(now, given_time)) - - # If the given time is before the current time, add one day to the future execution time - if future_exec < now: - future_exec += datetime.timedelta(days=1) - - return (future_exec - now).total_seconds() diff --git a/locales/bdays.en-US.json b/locales/bdays.en-US.json new file mode 100644 index 0000000..605ae48 --- /dev/null +++ b/locales/bdays.en-US.json @@ -0,0 +1,76 @@ +{ + "birthday_messages": [ + "\ud83c\udf82 Happy Birthday, **{0}**! \ud83c\udf89 Wishing you a day filled with joy and laughter.", + "\ud83c\udf88 It's party time! Happy Birthday, **{0}**! \ud83c\udf89", + "\ud83c\udf89 Another year older, another year wiser! Happy Birthday, **{0}**! \ud83c\udf82", + "\ud83c\udf1f Today's the day you shine brighter than ever! Happy Birthday, **{0}**! \ud83c\udf1f", + "\ud83c\udf81 Special day alert! It's **{0}**'s birthday! \ud83c\udf81", + "\ud83c\udf8a Hip, hip, hooray! It's **{0}**'s birthday today! \ud83c\udf8a", + "\ud83c\udf82 Cake and confetti time! Happy Birthday, **{0}**! \ud83c\udf89", + "\ud83c\udf08 Sending you a rainbow of happiness on your birthday, **{0}**! \ud83c\udf88", + "\ud83c\udf89 Let's raise a toast to **{0}** on their birthday! Cheers to another fantastic year! \ud83e\udd42", + "\ud83c\udf88 Birthdays are like sprinkles on the cupcake of life! Happy Birthday, **{0}**! \ud83e\uddc1", + "\ud83c\udf81 Gift-wrapped wishes for a wonderful birthday and an amazing year ahead, **{0}**! \ud83c\udf81", + "\ud83c\udf8a Time to blow out the candles and make a wish! Happy Birthday, **{0}**! \ud83c\udf82", + "\ud83c\udf1f It's your day to sparkle and shine, **{0}**! Happy Birthday! \u2728", + "\ud83c\udf88 May your birthday be as fabulous as you are, **{0}**! \ud83c\udf89", + "\ud83c\udf89 Here's to a year filled with success, happiness, and endless opportunities for **{0}**! Happy Birthday! \ud83e\udd73", + "\ud83c\udf81 Wishing **{0}** all the best on their special day! Happy Birthday! \ud83c\udf81", + "\ud83c\udf8a Another year of unforgettable memories begins today for **{0}**! Happy Birthday! \ud83c\udf8a", + "\ud83c\udf1f Your birthday is the perfect excuse to pamper yourself, **{0}**! Enjoy your special day! \ud83c\udf88", + "\ud83c\udf82 Age is just a number, and you're looking more fabulous with each passing year, **{0}**! Happy Birthday! \ud83d\udc95", + "\ud83c\udf89 Today, we celebrate the amazing person you are, **{0}**! Happy Birthday! \ud83c\udf82", + "\ud83c\udf88 Life's journey gets even more exciting as **{0}** celebrates another year of it! Happy Birthday! \ud83c\udf89", + "\ud83c\udf1f Happy Birthday to someone who makes every day brighter with their presence, **{0}**! \ud83c\udf1e", + "\ud83c\udf81 May this birthday be the beginning of the most extraordinary year yet for **{0}**! \ud83d\ude80", + "\ud83c\udf8a Birthdays are nature's way of telling us to eat more cake! Enjoy your special treat, **{0}**! \ud83c\udf70", + "\ud83c\udf89 Time to pop the confetti and make some fabulous birthday memories, **{0}**! \ud83c\udf82", + "\ud83c\udf88 Today, the world received a gift in the form of **{0}**! Happy Birthday! \ud83c\udf89", + "\ud83c\udf1f Wishing **{0}** health, happiness, and all the things they desire on their birthday! \ud83c\udf81", + "\ud83c\udf82 Cheers to **{0}** on another year of being amazing! Happy Birthday! \ud83c\udf89", + "\ud83c\udf89 It's **{0}**'s big day, so let loose and enjoy every moment! Happy Birthday! \ud83c\udf8a", + "\ud83c\udf88 Sending virtual hugs and lots of love to **{0}** on their special day! \ud83e\udd17\u2764\ufe0f", + "\ud83c\udf1f On your birthday, the world becomes a better place because of your presence, **{0}**! \ud83c\udf89", + "\ud83c\udf81 As you blow out the candles, know that your wishes are heard and your dreams matter. Happy Birthday, **{0}**! \ud83c\udf20", + "\ud83c\udf8a Here's to a birthday filled with laughter, love, and all the things that make you happy, **{0}**! Cheers! \ud83e\udd42", + "\ud83c\udf82 May your birthday be filled with delightful surprises and sweet moments, **{0}**! Enjoy your special day! \ud83c\udf88", + "\ud83c\udf89 It's time for the world to celebrate the incredible person that is **{0}**! Happy Birthday! \ud83c\udf8a", + "\ud83c\udf88 Another year, another chapter in the adventure of life for **{0}**! May this year be full of excitement and joy! \ud83c\udf1f", + "\ud83c\udf1f May this birthday mark the beginning of extraordinary achievements and unforgettable memories for **{0}**! \ud83c\udf81", + "\ud83c\udf82 Here's to a birthday filled with love, happiness, and all the good things you deserve, **{0}**! \ud83c\udf89", + "\ud83c\udf89 Sending virtual confetti and a big smile to **{0}** on their birthday! Let's celebrate! \ud83c\udf88", + "\ud83c\udf88 Time to indulge in cake and celebrate the wonderful human that is **{0}**! Happy Birthday! \ud83c\udf82", + "\ud83c\udf8a Today, we honor the amazing journey of **{0}**'s life! Happy Birthday! \ud83c\udf1f", + "\ud83c\udf1f Birthdays are a time for reflection, growth, and gratitude. Wishing **{0}** a wonderful birthday and year ahead! \ud83c\udf81", + "\ud83c\udf81 Another trip around the sun calls for a big celebration! Happy Birthday, **{0}**! \ud83c\udf89", + "\ud83c\udf82 As you blow out the candles, know that you are loved and cherished, **{0}**! Happy Birthday! \ud83c\udf88", + "\ud83c\udf89 It's a new age and a new opportunity to shine, **{0}**! May this year be your best yet! \ud83c\udf1f", + "\ud83c\udf88 On your special day, may you be surrounded by love, happiness, and everything that brings you joy, **{0}**! \ud83c\udf82", + "\ud83c\udf1f Today, we celebrate **{0}** and the unique light they bring into the world! Happy Birthday! \ud83c\udf89", + "\ud83c\udf81 Wishing a fantastic birthday to the one and only **{0}**! May this day be filled with laughter and love! \ud83c\udf88", + "\ud83c\udf8a May your birthday be as extraordinary as the person you are, **{0}**! Cheers to you! \ud83e\udd73", + "\ud83c\udf82 You're not just a year older; you're a year more incredible! Happy Birthday, **{0}**! \ud83c\udf1f", + "\ud83c\udf89 It's time to embrace the joy, love, and happiness that come with birthdays! Enjoy every moment, **{0}**! \ud83c\udf88", + "\ud83c\udf88 Another year, another chance to create beautiful memories. Happy Birthday, **{0}**! \ud83c\udf82", + "\ud83c\udf1f Your birthday is a reminder of how much you mean to all of us! Wishing you the best day ever, **{0}**! \ud83c\udf81", + "\ud83c\udf81 Sending warm birthday wishes and virtual hugs to **{0}** on their special day! \ud83e\udd17\u2764\ufe0f", + "\ud83c\udf89 Today, we celebrate the unique and wonderful person that is **{0}**! Happy Birthday! \ud83c\udf82", + "\ud83c\udf88 Here's to a birthday filled with laughter, love, and all the things that make you smile, **{0}**! \ud83c\udf1f", + "\ud83c\udf8a It's a day to be spoiled and celebrated, **{0}**! Wishing you the happiest of birthdays! \ud83c\udf81", + "\ud83c\udf82 As you turn another year older, know that you are loved and cherished, **{0}**! Happy Birthday! \ud83c\udf89" + ], + "months": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ] +} diff --git a/settings/responses/levels.en-US.json b/locales/levels.en-US.json similarity index 97% rename from settings/responses/levels.en-US.json rename to locales/levels.en-US.json index c9fe5ca..2d7cee7 100644 --- a/settings/responses/levels.en-US.json +++ b/locales/levels.en-US.json @@ -14,7 +14,7 @@ "Rumor has it that reaching **Level {}** grants you the ability to mildly impress others.", "You got to **Level {}**! Prepare for a slightly raised eyebrow of acknowledgement.", "Congratulations on reaching **Level {}**. It's a modest achievement, to say the least.", - "Congratulations on **Level {}**! You must be SO proud of yourself. \uD83D\uDE44", + "Congratulations on **Level {}**! You must be SO proud of yourself. \ud83d\ude44", "You've reached **Level {}**! Your achievement is about as significant as a grain of sand.", "Congratulations on your ascent to **Level {}**. It's a small step for mankind.", "At **Level {}**, you're like a firework that fizzles out before it even begins.", @@ -39,7 +39,7 @@ "*elevator music* Welcome to **level {}**." ], "21-40": [ - "**Level {}** 👍", + "**Level {}** \ud83d\udc4d", "Look who's slacking off work to level up on Discord. **Level {}** and counting!", "**Level {}**? Have you considered that there might be an entire world outside of Discord?", "Wow, you've climbed to **level {}**. Is Discord your full-time job now?", @@ -68,7 +68,7 @@ "Lol it took you this long to reach **Level {}**.", "**{}**.", "**Level {}**???? Who are you? Gear?", - "Yay you reached **Level {}**!! :3 UwU \uD83E\uDD8B \uD83D\uDDA4 (nobody cares)", + "Yay you reached **Level {}**!! :3 UwU \ud83e\udd8b \ud83d\udda4 (nobody cares)", "Conragulasions your level **{}** now.", "Hey man congrats on reaching **Level {}**. I mean it. GG.", "You reached **Level {}**!! What's it like being a loser?", @@ -76,4 +76,4 @@ "CONGRATIONS LEVE **{}**", "Hahahahahahahahahhahahahaahahah. **Level {}**." ] -} \ No newline at end of file +} diff --git a/settings/responses/strings.en-US.json b/locales/strings.en-US.json similarity index 85% rename from settings/responses/strings.en-US.json rename to locales/strings.en-US.json index eb8bc85..2470c31 100644 --- a/settings/responses/strings.en-US.json +++ b/locales/strings.en-US.json @@ -16,7 +16,10 @@ "admin_sync_error_description": "An error occurred while syncing: {0}", "admin_sync_error_title": "Sync Error", "admin_sync_title": "Sync Successful", - "bet_limit": "❌ | **{0}** you cannot place any bets above **${1}**.", + "balance_author": "{0}'s wallet", + "balance_cash": "**Cash**: ${0}", + "balance_footer": "check out /daily", + "bet_limit": "\u274c | **{0}** you cannot place any bets above **${1}**.", "birthday_add_invalid_date": "The date you entered is invalid.", "birthday_add_success_author": "Birthday Set", "birthday_add_success_description": "your birthday has been set to **{0} {1}**.", @@ -29,7 +32,7 @@ "birthday_delete_success_description": "your birthday has been deleted from this server.", "birthday_leap_year": "February 29", "birthday_upcoming_author": "Upcoming Birthdays!", - "birthday_upcoming_description_line": "🎂 {0} - {1}", + "birthday_upcoming_description_line": "\ud83c\udf82 {0} - {1}", "birthday_upcoming_no_birthdays": "there are no upcoming birthdays in this server.", "birthday_upcoming_no_birthdays_author": "No Upcoming Birthdays", "blackjack_bet": "Bet ${0}", @@ -42,15 +45,15 @@ "blackjack_error": "I.. don't know if you won?", "blackjack_error_description": "This is an error, please report it.", "blackjack_footer": "Game finished", + "blackjack_hit": "hit", "blackjack_lost": "You lost **${0}**.", "blackjack_lost_generic": "You lost..", "blackjack_player_hand": "**You**\nScore: {0}\n*Hand: {1}*", + "blackjack_stand": "stand", "blackjack_title": "BlackJack", "blackjack_won_21": "You won with a score of 21!", "blackjack_won_natural": "You won with a natural hand!", "blackjack_won_payout": "You won **${0}**.", - "blackjack_hit": "hit", - "blackjack_stand": "stand", "boost_default_description": "Thanks for boosting, **{0}**!!", "boost_default_title": "New Booster", "case_case_field": "Case:", @@ -71,7 +74,7 @@ "case_reason_update_author": "Case Reason Updated", "case_reason_update_description": "case `{0}` reason has been updated.", "case_target_field": "Target:", - "case_target_field_value": "`{0}` 🎯", + "case_target_field_value": "`{0}` \ud83c\udfaf", "case_type_field": "Type:", "case_type_field_value": "`{0}`", "case_type_field_value_with_duration": "`{0} ({1})`", @@ -87,6 +90,7 @@ "config_boost_module_disabled": "the boost module was successfully disabled.", "config_boost_template_field": "New Template:", "config_boost_template_updated": "the boost message template has been updated.", + "config_boost_total_count": "Total server boosts: {0}", "config_example_next_footer": "An example will be sent next.", "config_level_channel_set": "all level announcements will be sent in {0}.", "config_level_current_channel_set": "members will receive level announcements in their current channel.", @@ -98,15 +102,15 @@ "config_level_template_updated": "the level template was successfully updated.", "config_level_type_example": "Example:", "config_level_type_generic": "level announcements will be **generic messages**.", - "config_level_type_generic_example": "📈 | **lucas** you have reached **Level 15**.", + "config_level_type_generic_example": "\ud83d\udcc8 | **lucas** you have reached **Level 15**.", "config_level_type_whimsical": "level announcements will be **sarcastic comments**.", - "config_level_type_whimsical_example": "📈 | **lucas** Lol it took you this long to reach **Level 15**.", + "config_level_type_whimsical_example": "\ud83d\udcc8 | **lucas** Lol it took you this long to reach **Level 15**.", "config_modlog_channel_set": "moderation logs will be sent in {0}.", "config_modlog_info_author": "Moderation Log Configuration", - "config_modlog_info_commands_name": "📖 Case commands", + "config_modlog_info_commands_name": "\ud83d\udcd6 Case commands", "config_modlog_info_commands_value": "`/cases` - View all cases in this server\n`/case ` - View a specific case\n`/editcase ` - Update a case reason", "config_modlog_info_description": "This channel has been set as the moderation log channel for **{0}**. All moderation actions issued with Lumi will be logged here as cases.", - "config_modlog_info_warning_name": "⚠️ Warning", + "config_modlog_info_warning_name": "\u26a0\ufe0f Warning", "config_modlog_info_warning_value": "Changing the mod-log channel in the future will make old cases uneditable in this channel.", "config_modlog_permission_error": "I don't have perms to send messages in that channel. Please fix & try again.", "config_prefix_get": "the current prefix for this server is `{0}`", @@ -115,21 +119,24 @@ "config_show_author": "{0} Configuration", "config_show_birthdays": "Birthdays", "config_show_boost_announcements": "Boost announcements", - "config_show_default_enabled": "✅ Enabled (default)", - "config_show_disabled": "❌ Disabled", - "config_show_enabled": "✅ Enabled", + "config_show_default_enabled": "\u2705 Enabled (default)", + "config_show_disabled": "\u274c Disabled", + "config_show_enabled": "\u2705 Enabled", "config_show_guide": "Guide: {0}", "config_show_level_announcements": "Level announcements", "config_show_moderation_log": "Moderation Log", - "config_show_moderation_log_channel_deleted": "⚠️ **Not configured** (channel deleted?)", - "config_show_moderation_log_enabled": "✅ {0}", - "config_show_moderation_log_not_configured": "⚠️ **Not configured yet**", + "config_show_moderation_log_channel_deleted": "\u26a0\ufe0f **Not configured** (channel deleted?)", + "config_show_moderation_log_enabled": "\u2705 {0}", + "config_show_moderation_log_not_configured": "\u26a0\ufe0f **Not configured yet**", "config_show_new_member_greets": "New member greets", "config_welcome_channel_set": "I will announce new members in {0}.", "config_welcome_module_already_disabled": "the greeting module was already disabled.", "config_welcome_module_disabled": "the greeting module was successfully disabled.", "config_welcome_template_field": "New Template:", "config_welcome_template_updated": "the welcome message template has been updated.", + "config_xpreward_added": "xp reward for **Level {0}** with role {1} has been added.", + "config_xpreward_removed": "xp reward for **Level {0}** has been removed.", + "config_xpreward_show_no_rewards": "**There are no XP rewards set up yet.**\n\nTo add a reward, use `/config xpreward add`.", "daily_already_claimed_author": "Already Claimed", "daily_already_claimed_description": "you can claim your daily reward again .", "daily_already_claimed_footer": "Daily reset is at 7 AM EST", @@ -137,12 +144,12 @@ "daily_success_claim_author": "Reward Claimed", "daily_success_claim_description": "you claimed your reward of **${0}**!", "default_level_up_message": "**{0}** you have reached **Level {1}**.", + "dev_clear_tree": "The application command tree has been cleared.", + "dev_sync_tree": "The application command tree has been synced.", "error_actionable_hierarchy_bot": "I don't have permission to perform this action on this user due to role hierarchy.", "error_actionable_hierarchy_user": "you don't have permission to perform this action on this user due to role hierarchy.", "error_actionable_self": "you can't perform this action on yourself.", "error_already_playing_blackjack": "you already have a game of blackjack running.", - "error_bad_argument_author": "Bad Argument", - "error_bad_argument_description": "{0}", "error_birthdays_disabled_author": "Birthdays Disabled", "error_birthdays_disabled_description": "birthdays are disabled in this server.", "error_birthdays_disabled_footer": "Contact a mod to enable them.", @@ -150,6 +157,7 @@ "error_boost_image_url_invalid": "the image URL must end with `.jpg` or `.png`.", "error_bot_missing_permissions_author": "Bot Missing Permissions", "error_bot_missing_permissions_description": "Lumi lacks the required permissions to run this command.", + "error_cant_use_buttons": "You can't use these buttons, they're someone else's!", "error_command_cooldown_author": "Command Cooldown", "error_command_cooldown_description": "try again in **{0:02d}:{1:02d}**.", "error_command_not_found": "No command called \"{0}\" found.", @@ -167,18 +175,24 @@ "error_no_private_message_author": "Guild Only", "error_no_private_message_description": "this command can only be used in servers.", "error_not_enough_cash": "you don't have enough cash.", - "error_not_owner_author": "Owner Only", - "error_not_owner_description": "this command requires Lumi ownership permissions.", + "error_not_owner": "{0} tried to use a bot admin command ({1})", + "error_not_owner_unknown": "Unknown", "error_out_of_time": "you ran out of time.", "error_out_of_time_economy": "you ran out of time. Your bet was forfeited.", "error_private_message_only_author": "Private Message Only", "error_private_message_only_description": "this command can only be used in private messages.", "error_unknown_error_author": "Unknown Error", "error_unknown_error_description": "an unknown error occurred. Please try again later.", + "give_error_bot": "you can't give money to a bot.", + "give_error_insufficient_funds": "you don't have enough cash.", + "give_error_invalid_amount": "invalid amount.", + "give_error_self": "you can't give money to yourself.", + "give_success": "you gave **${1}** to {2}.", "greet_default_description": "_ _\n**Welcome** to **{0}**", - "greet_template_description": "↓↓↓\n{0}", + "greet_template_description": "\u2193\u2193\u2193\n{0}", + "help_footer": "Help Service", "help_use_prefix": "Please use Lumi's prefix to get help. Type `{0}help`", - "info_api_version": "**API:** v{0}\n", + "info_api_version": "**discord.py:** v{0}\n", "info_database_records": "**Database:** {0} records", "info_latency": "**Latency:** {0}ms\n", "info_memory": "**Memory:** {0:.2f} MB\n", @@ -192,6 +206,7 @@ "intro_no_guild": "you're not in a server that supports introductions.", "intro_no_guild_author": "Server Not Supported", "intro_post_confirmation": "your introduction has been posted in {0}!", + "intro_post_confirmation_author": "Introduction Posted", "intro_preview_field": "**{0}:** {1}\n\n", "intro_question_footer": "Type your answer below.", "intro_service_name": "Introduction Service", @@ -203,15 +218,16 @@ "intro_timeout_author": "Timeout", "intro_too_long": "your answer was too long, please keep it below 200 characters.", "intro_too_long_author": "Answer Too Long", + "invite_author": "Invite Lumi", "invite_button_text": "Invite Lumi", - "invite_description": "Thanks for inviting me to your server!", - "level_up": "📈 | **{0}** you have reached **Level {1}**.", - "level_up_prefix": "📈 | **{0}** ", + "invite_description": "thanks for inviting me to your server!", + "level_up": "\ud83d\udcc8 | **{0}** you have reached **Level {1}**.", + "level_up_prefix": "\ud83d\udcc8 | **{0}** ", "lumi_exception_blacklisted": "User is blacklisted", - "lumi_exception_generic": "An error occurred.", + "lumi_exception_generic": "An error occurred. Please try again later.", "mod_ban_dm": "**{0}** you have been banned from `{1}`.\n\n**Reason:** `{2}`", "mod_banned_author": "User Banned", - "mod_banned_user": "user with ID `{0}` has been banned.", + "mod_banned_user": "user `{0}` has been banned.", "mod_dm_not_sent": "Failed to notify them in DM", "mod_dm_sent": "notified them in DM", "mod_kick_dm": "**{0}** you have been kicked from `{1}`.\n\n**Reason:** `{2}`", @@ -230,7 +246,8 @@ "mod_timed_out_author": "User Timed Out", "mod_timed_out_user": "user `{0}` has been timed out.", "mod_timeout_dm": "**{0}** you have been timed out in `{1}` for `{2}`.\n\n**Reason:** `{3}`", - "mod_unbanned": "user with ID `{0}` has been unbanned.", + "mod_timeout_too_long": "you cannot timeout a user for longer than 27 days.", + "mod_unbanned": "user `{0}` has been unbanned.", "mod_unbanned_author": "User Unbanned", "mod_untimed_out": "timeout has been removed for user `{0}`.", "mod_untimed_out_author": "User Timeout Removed", @@ -239,10 +256,15 @@ "mod_warned_user": "user `{0}` has been warned.", "ping_author": "I'm online!", "ping_footer": "Latency: {0}ms", - "ping_pong": "Pong!", + "ping_pong": "pong!", "ping_uptime": "I've been online since .", - "stats_blackjack": "🃏 | You've played **{0}** games of BlackJack, betting a total of **${1}**. You won **{2}** of those games with a total payout of **${3}**.", - "stats_slots": "🎰 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.", + "slowmode_channel_not_found": "Channel not found.", + "slowmode_current_value": "The current slowmode for {0} is **{1}s**.", + "slowmode_forbidden": "I don't have permission to change the slowmode in that channel.", + "slowmode_invalid_duration": "Slowmode duration must be between 0 and 21600 seconds.", + "slowmode_success": "Slowmode set to **{0}s** in {1}.", + "stats_blackjack": "\ud83c\udccf | You've played **{0}** games of BlackJack, betting a total of **${1}**. You won **{2}** of those games with a total payout of **${3}**.", + "stats_slots": "\ud83c\udfb0 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.", "trigger_already_exists": "Failed to add custom reaction. This text already contains another trigger. To avoid unexpected behavior, please delete it before adding a new one.", "trigger_limit_reached": "Failed to add custom reaction. You have reached the limit of 100 custom reactions for this server.", "triggers_add_author": "Custom Reaction Created", @@ -283,14 +305,5 @@ "xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`", "xp_level": "Level {0}", "xp_progress": "Progress to next level", - "xp_server_rank": "Server Rank: #{0}", - "balance_cash": "**Cash**: ${0}", - "balance_author": "{0}'s wallet", - "balance_footer": "check out /daily", - "give_error_self": "you can't give money to yourself.", - "give_error_bot": "you can't give money to a bot.", - "give_error_invalid_amount": "invalid amount.", - "give_error_insufficient_funds": "you don't have enough cash.", - "give_success": "**{0}** gave **${1}** to {2}.", - "error_cant_use_buttons": "You can't use these buttons, they're someone else's!" -} \ No newline at end of file + "xp_server_rank": "Server Rank: #{0}" +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..63a81ee --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +import asyncio +import sys + +import discord +from discord.ext import commands +from loguru import logger + +from lib.client import Luminara +from lib.const import CONST +from lib.help import LuminaraHelp +from services.config_service import GuildConfig + +logger.remove() +logger.add(sys.stdout, format=CONST.LOG_FORMAT, colorize=True, level=CONST.LOG_LEVEL) + + +async def get_prefix(bot: Luminara, message: discord.Message) -> list[str]: + extras = GuildConfig.get_prefix(message) + return commands.when_mentioned_or(*extras)(bot, message) + + +async def main() -> None: + if not CONST.TOKEN: + logger.error("No token provided") + return + + lumi: Luminara = Luminara( + owner_ids=CONST.OWNER_IDS, + intents=discord.Intents.all(), + command_prefix=get_prefix, + allowed_mentions=discord.AllowedMentions(everyone=False), + case_insensitive=True, + strip_after_prefix=True, + help_command=LuminaraHelp(), + ) + + try: + await lumi.start(CONST.TOKEN, reconnect=True) + + except KeyboardInterrupt: + logger.info("Keyboard interrupt detected. Shutting down...") + + finally: + logger.info("Closing resources...") + await lumi.shutdown() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/admin/__init__.py b/modules/admin/__init__.py index a442d51..e69de29 100644 --- a/modules/admin/__init__.py +++ b/modules/admin/__init__.py @@ -1,45 +0,0 @@ -from typing import Optional - -import discord -from discord.ext import commands - -from modules.admin import award, blacklist, sql, sync - - -class BotAdmin(commands.Cog, name="Bot Admin"): - """ - This module is intended for commands that only bot owners can do. - For server configuration with Lumi, see the "config" module. - """ - - def __init__(self, client): - self.client = client - - @commands.command(name="award") - @commands.is_owner() - async def award_command(self, ctx, user: discord.User, *, amount: int): - return await award.cmd(ctx, user, amount) - - @commands.command(name="sqlselect", aliases=["sqls"]) - @commands.is_owner() - async def select(self, ctx, *, query: str): - return await sql.select_cmd(ctx, query) - - @commands.command(name="sqlinject", aliases=["sqli"]) - @commands.is_owner() - async def inject(self, ctx, *, query: str): - return await sql.inject_cmd(ctx, query) - - @commands.command(name="blacklist") - @commands.is_owner() - async def blacklist(self, ctx, user: discord.User, *, reason: Optional[str] = None): - return await blacklist.blacklist_user(ctx, user, reason) - - @commands.command(name="sync") - @commands.is_owner() - async def sync_command(self, ctx): - await sync.sync_commands(self.client, ctx) - - -def setup(client): - client.add_cog(BotAdmin(client)) diff --git a/modules/admin/admin.py b/modules/admin/admin.py new file mode 100644 index 0000000..6053bea --- /dev/null +++ b/modules/admin/admin.py @@ -0,0 +1,109 @@ +import mysql.connector +from discord.ext import commands + +import lib.format +from db import database +from lib.const import CONST +from lib.format import shorten +from ui.embeds import Builder + + +class Sql(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.select_cmd.usage = lib.format.generate_usage(self.select_cmd) + self.inject_cmd.usage = lib.format.generate_usage(self.inject_cmd) + + @commands.command(name="sqlselect", aliases=["sqls"]) + @commands.is_owner() + async def select_cmd( + self, + ctx: commands.Context[commands.Bot], + *, + query: str, + ) -> None: + """ + Execute a SQL SELECT query. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + query : str + The SQL query to execute. + """ + if query.lower().startswith("select "): + query = query[7:] + + try: + results = database.select_query(f"SELECT {query}") + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_sql_select_title"], + description=CONST.STRINGS["admin_sql_select_description"].format( + shorten(query, 200), + shorten(str(results), 200), + ), + hide_name_in_description=True, + ) + except mysql.connector.Error as error: + embed = Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_sql_select_error_title"], + description=CONST.STRINGS["admin_sql_select_error_description"].format( + shorten(query, 200), + shorten(str(error), 200), + ), + hide_name_in_description=True, + ) + + await ctx.send(embed=embed, ephemeral=True) + + @commands.command(name="sqlinject", aliases=["sqli"]) + @commands.is_owner() + async def inject_cmd( + self, + ctx: commands.Context[commands.Bot], + *, + query: str, + ) -> None: + """ + Execute a SQL INJECT query. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + query : str + The SQL query to execute. + """ + try: + database.execute_query(query) + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_sql_inject_title"], + description=CONST.STRINGS["admin_sql_inject_description"].format( + shorten(query, 200), + ), + hide_name_in_description=True, + ) + except mysql.connector.Error as error: + embed = Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_sql_inject_error_title"], + description=CONST.STRINGS["admin_sql_inject_error_description"].format( + shorten(query, 200), + shorten(str(error), 200), + ), + hide_name_in_description=True, + ) + + await ctx.send(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Sql(bot)) diff --git a/modules/admin/award.py b/modules/admin/award.py index 9cd18ab..c9c9d51 100644 --- a/modules/admin/award.py +++ b/modules/admin/award.py @@ -1,23 +1,53 @@ import discord +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +import lib.format +from lib.const import CONST from services.currency_service import Currency +from ui.embeds import Builder -async def cmd(ctx, user: discord.User, amount: int): - # Currency handler - curr = Currency(user.id) - curr.add_balance(amount) - curr.push() +class Award(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.award_command.usage = lib.format.generate_usage(self.award_command) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["admin_award_title"], - description=CONST.STRINGS["admin_award_description"].format( - Currency.format(amount), - user.name, - ), - ) + @commands.command(name="award", aliases=["aw"]) + @commands.is_owner() + async def award_command( + self, + ctx: commands.Context[commands.Bot], + user: discord.User, + amount: int, + ) -> None: + """ + Award a user with a specified amount of currency. - await ctx.respond(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + user : discord.User + The user to award. + amount : int + The amount of currency to award. + """ + curr = Currency(user.id) + curr.add_balance(amount) + curr.push() + + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_award_title"], + description=CONST.STRINGS["admin_award_description"].format( + Currency.format(amount), + user.name, + ), + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Award(bot)) diff --git a/modules/admin/blacklist.py b/modules/admin/blacklist.py index 815f649..f9c1538 100644 --- a/modules/admin/blacklist.py +++ b/modules/admin/blacklist.py @@ -1,26 +1,51 @@ -from typing import Optional - import discord +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +import lib.format +from lib.const import CONST from services.blacklist_service import BlacklistUserService +from ui.embeds import Builder -async def blacklist_user( - ctx, - user: discord.User, - reason: Optional[str] = None, -) -> None: - blacklist_service = BlacklistUserService(user.id) - blacklist_service.add_to_blacklist(reason) +class Blacklist(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.blacklist_command.usage = lib.format.generate_usage(self.blacklist_command) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["admin_blacklist_author"], - description=CONST.STRINGS["admin_blacklist_description"].format(user.name), - footer_text=CONST.STRINGS["admin_blacklist_footer"], - hide_timestamp=True, - ) + @commands.command(name="blacklist") + @commands.is_owner() + async def blacklist_command( + self, + ctx: commands.Context[commands.Bot], + user: discord.User, + *, + reason: str | None = None, + ) -> None: + """ + Blacklist a user from the bot. - await ctx.send(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + user : discord.User + The user to blacklist. + reason : str | None, optional + """ + blacklist_service = BlacklistUserService(user.id) + blacklist_service.add_to_blacklist(reason) + + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["admin_blacklist_author"], + description=CONST.STRINGS["admin_blacklist_description"].format(user.name), + footer_text=CONST.STRINGS["admin_blacklist_footer"], + hide_time=True, + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Blacklist(bot)) diff --git a/modules/admin/dev.py b/modules/admin/dev.py new file mode 100644 index 0000000..9591290 --- /dev/null +++ b/modules/admin/dev.py @@ -0,0 +1,70 @@ +import discord +from discord.ext import commands + +import lib.format +from lib.const import CONST + + +class Dev(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.sync.usage = lib.format.generate_usage(self.sync) + self.clear.usage = lib.format.generate_usage(self.clear) + + @commands.group(name="dev", description="Lumi developer commands") + @commands.guild_only() + @commands.is_owner() + async def dev(self, ctx: commands.Context[commands.Bot]) -> None: + pass + + @dev.command( + name="sync_tree", + aliases=["sync"], + ) + async def sync( + self, + ctx: commands.Context[commands.Bot], + guild: discord.Guild | None = None, + ) -> None: + """ + Sync the bot's tree to the specified guild. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + guild : discord.Guild | None, optional + The guild to sync the tree to, by default None. + """ + if guild: + self.bot.tree.copy_global_to(guild=guild) + + await self.bot.tree.sync(guild=guild) + + await ctx.send(content=CONST.STRINGS["dev_sync_tree"]) + + @dev.command( + name="clear_tree", + aliases=["clear"], + ) + async def clear( + self, + ctx: commands.Context[commands.Bot], + guild: discord.Guild | None = None, + ) -> None: + """ + Clear the bot's tree for the specified guild. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + guild : discord.Guild | None, optional + """ + self.bot.tree.clear_commands(guild=guild) + + await ctx.send(content=CONST.STRINGS["dev_clear_tree"]) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Dev(bot)) diff --git a/modules/admin/sql.py b/modules/admin/sql.py deleted file mode 100644 index fd16daa..0000000 --- a/modules/admin/sql.py +++ /dev/null @@ -1,60 +0,0 @@ -import mysql.connector - -from db import database -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.formatter import shorten - - -async def select_cmd(ctx, query: str): - if query.lower().startswith("select "): - query = query[7:] - - try: - results = database.select_query(f"SELECT {query}") - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["admin_sql_select_title"], - description=CONST.STRINGS["admin_sql_select_description"].format( - shorten(query, 200), - shorten(str(results), 200), - ), - show_name=False, - ) - except mysql.connector.Error as error: - embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["admin_sql_select_error_title"], - description=CONST.STRINGS["admin_sql_select_error_description"].format( - shorten(query, 200), - shorten(str(error), 200), - ), - show_name=False, - ) - - return await ctx.respond(embed=embed, ephemeral=True) - - -async def inject_cmd(ctx, query: str): - try: - database.execute_query(query) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["admin_sql_inject_title"], - description=CONST.STRINGS["admin_sql_inject_description"].format( - shorten(query, 200), - ), - show_name=False, - ) - except mysql.connector.Error as error: - embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["admin_sql_inject_error_title"], - description=CONST.STRINGS["admin_sql_inject_error_description"].format( - shorten(query, 200), - shorten(str(error), 200), - ), - show_name=False, - ) - - await ctx.respond(embed=embed, ephemeral=True) diff --git a/modules/admin/sync.py b/modules/admin/sync.py deleted file mode 100644 index 8195ede..0000000 --- a/modules/admin/sync.py +++ /dev/null @@ -1,20 +0,0 @@ -import discord - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException - - -async def sync_commands(client, ctx): - try: - await client.sync_commands() - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["admin_sync_title"], - description=CONST.STRINGS["admin_sync_description"], - ) - await ctx.send(embed=embed) - except discord.HTTPException as e: - raise LumiException( - CONST.STRINGS["admin_sync_error_description"].format(e), - ) from e diff --git a/modules/birthdays/__init__.py b/modules/birthdays/__init__.py index 2605e2f..e69de29 100644 --- a/modules/birthdays/__init__.py +++ b/modules/birthdays/__init__.py @@ -1,46 +0,0 @@ -import datetime - -import discord -import pytz -from discord.commands import SlashCommandGroup -from discord.ext import commands, tasks - -from lib import checks -from lib.constants import CONST -from modules.birthdays import birthday, daily_check - - -class Birthdays(commands.Cog): - def __init__(self, client): - self.client = client - self.daily_birthday_check.start() - - birthday = SlashCommandGroup( - name="birthday", - description="Birthday commands.", - contexts={discord.InteractionContextType.guild}, - ) - - @birthday.command(name="set", description="Set your birthday in this server.") - @checks.birthdays_enabled() - @discord.commands.option(name="month", choices=CONST.BIRTHDAY_MONTHS) - async def set_birthday(self, ctx, month, day: int): - index = CONST.BIRTHDAY_MONTHS.index(month) + 1 - await birthday.add(ctx, month, index, day) - - @birthday.command(name="delete", description="Delete your birthday in this server.") - async def delete_birthday(self, ctx): - await birthday.delete(ctx) - - @birthday.command(name="upcoming", description="Shows the upcoming birthdays.") - @checks.birthdays_enabled() - async def upcoming_birthdays(self, ctx): - await birthday.upcoming(ctx) - - @tasks.loop(time=datetime.time(hour=12, minute=0, tzinfo=pytz.UTC)) # 12 PM UTC - async def daily_birthday_check(self): - await daily_check.daily_birthday_check(self.client) - - -def setup(client): - client.add_cog(Birthdays(client)) diff --git a/modules/birthdays/birthday.py b/modules/birthdays/birthday.py index 6fe136c..94fe8fa 100644 --- a/modules/birthdays/birthday.py +++ b/modules/birthdays/birthday.py @@ -1,85 +1,211 @@ +import asyncio import calendar import datetime +import random +from zoneinfo import ZoneInfo import discord -from discord.ext import commands +from discord import app_commands +from discord.ext import commands, tasks +from loguru import logger -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.birthday_service import Birthday +from lib.checks import birthdays_enabled +from lib.const import CONST +from services.birthday_service import BirthdayService +from services.config_service import GuildConfig +from ui.embeds import Builder -async def add(ctx, month, month_index, day): - leap_year = 2020 - max_days = calendar.monthrange(leap_year, month_index)[1] +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +class Birthday(commands.GroupCog, group_name="birthday"): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.daily_birthday_check.start() - if not 1 <= day <= max_days: - raise commands.BadArgument(CONST.STRINGS["birthday_add_invalid_date"]) + @tasks.loop(time=datetime.time(hour=12, minute=0, tzinfo=ZoneInfo("UTC"))) + async def daily_birthday_check(self): + logger.info(CONST.STRINGS["birthday_check_started"]) + birthdays_today = BirthdayService.get_birthdays_today() + processed_birthdays = 0 + failed_birthdays = 0 - date_obj = datetime.datetime(leap_year, month_index, day) + if birthdays_today: + for user_id, guild_id in birthdays_today: + try: + guild = await self.bot.fetch_guild(guild_id) + member = await guild.fetch_member(user_id) + guild_config = GuildConfig(guild.id) - birthday = Birthday(ctx.author.id, ctx.guild.id) - birthday.set(date_obj) + if not guild_config.birthday_channel_id: + logger.debug( + CONST.STRINGS["birthday_check_skipped"].format(guild.id), + ) + continue - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["birthday_add_success_author"], - description=CONST.STRINGS["birthday_add_success_description"].format( - month, - day, - ), - show_name=True, - ) - await ctx.respond(embed=embed) + message = random.choice(CONST.BIRTHDAY_MESSAGES) + embed = Builder.create_embed( + theme="success", + author_text="Happy Birthday!", + description=message.format(member.name), + hide_name_in_description=True, + ) + embed.set_image(url=CONST.BIRTHDAY_GIF_URL) + channel = await guild.fetch_channel(guild_config.birthday_channel_id) + assert isinstance(channel, discord.TextChannel) + await channel.send(embed=embed, content=member.mention) + logger.debug( + CONST.STRINGS["birthday_check_success"].format( + member.id, + guild.id, + channel.id, + ), + ) + processed_birthdays += 1 -async def delete(ctx): - Birthday(ctx.author.id, ctx.guild.id).delete() + except Exception as e: + logger.warning( + CONST.STRINGS["birthday_check_error"].format(user_id, guild_id, e), + ) + failed_birthdays += 1 - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["birthday_delete_success_author"], - description=CONST.STRINGS["birthday_delete_success_description"], - show_name=True, - ) - await ctx.respond(embed=embed) + # wait one second to avoid rate limits + await asyncio.sleep(1) - -async def upcoming(ctx): - upcoming_birthdays = Birthday.get_upcoming_birthdays(ctx.guild.id) - - if not upcoming_birthdays: - embed = EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["birthday_upcoming_no_birthdays_author"], - description=CONST.STRINGS["birthday_upcoming_no_birthdays"], - show_name=True, + logger.info( + CONST.STRINGS["birthday_check_finished"].format( + processed_birthdays, + failed_birthdays, + ), ) - await ctx.respond(embed=embed) - return - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["birthday_upcoming_author"], - description="", - show_name=False, + @app_commands.command(name="set") + @birthdays_enabled() + @app_commands.choices( + month=[discord.app_commands.Choice(name=month_name, value=month_name) for month_name in CONST.BIRTHDAY_MONTHS], ) - embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT) + async def set_birthday( + self, + interaction: discord.Interaction, + month: str, + day: int, + ) -> None: + """ + Set your birthday. - birthday_lines = [] - for user_id, birthday in upcoming_birthdays[:10]: - try: - member = await ctx.guild.fetch_member(user_id) - birthday_date = datetime.datetime.strptime(birthday, "%m-%d") - formatted_birthday = birthday_date.strftime("%B %-d") - birthday_lines.append( - CONST.STRINGS["birthday_upcoming_description_line"].format( - member.name, - formatted_birthday, - ), + Parameters + ---------- + interaction : discord.Interaction + The interaction object. + month : Month + The month of your birthday. + day : int + The day of your birthday. + """ + assert interaction.guild + leap_year = 2020 + month_index = CONST.BIRTHDAY_MONTHS.index(month) + 1 + max_days = calendar.monthrange(leap_year, month_index)[1] + + if not 1 <= day <= max_days: + raise commands.BadArgument(CONST.STRINGS["birthday_add_invalid_date"]) + + date_obj = datetime.datetime(leap_year, month_index, day, tzinfo=ZoneInfo("US/Eastern")) + + birthday = BirthdayService(interaction.user.id, interaction.guild.id) + birthday.set(date_obj) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["birthday_add_success_author"], + description=CONST.STRINGS["birthday_add_success_description"].format( + month, + day, + ), + ) + + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="remove") + async def remove_birthday( + self, + interaction: discord.Interaction, + ) -> None: + """ + Remove your birthday. + + Parameters + ---------- + interaction : discord.Interaction + The interaction object. + """ + assert interaction.guild + BirthdayService(interaction.user.id, interaction.guild.id).delete() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["birthday_delete_success_author"], + description=CONST.STRINGS["birthday_delete_success_description"], + ) + + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="upcoming") + @birthdays_enabled() + async def upcoming_birthdays( + self, + interaction: discord.Interaction, + ) -> None: + """ + View upcoming birthdays. + + Parameters + ---------- + interaction : discord.Interaction + The interaction object. + """ + assert interaction.guild + upcoming_birthdays = BirthdayService.get_upcoming_birthdays(interaction.guild.id) + + if not upcoming_birthdays: + embed = Builder.create_embed( + theme="warning", + user_name=interaction.user.name, + author_text=CONST.STRINGS["birthday_upcoming_no_birthdays_author"], + description=CONST.STRINGS["birthday_upcoming_no_birthdays"], ) - except (discord.HTTPException, ValueError): - continue + await interaction.response.send_message(embed=embed) + return - embed.description = "\n".join(birthday_lines) - await ctx.respond(embed=embed) + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["birthday_upcoming_author"], + description="", + ) + embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT) + + birthday_lines: list[str] = [] + for user_id, birthday in upcoming_birthdays[:10]: + try: + member = await interaction.guild.fetch_member(user_id) + birthday_date = datetime.datetime.strptime(birthday, "%m-%d").replace(tzinfo=ZoneInfo("US/Eastern")) + formatted_birthday = birthday_date.strftime("%B %-d") + birthday_lines.append( + CONST.STRINGS["birthday_upcoming_description_line"].format( + member.name, + formatted_birthday, + ), + ) + except (discord.HTTPException, ValueError): + continue + + embed.description = "\n".join(birthday_lines) + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Birthday(bot)) diff --git a/modules/birthdays/daily_check.py b/modules/birthdays/daily_check.py deleted file mode 100644 index 0abd10b..0000000 --- a/modules/birthdays/daily_check.py +++ /dev/null @@ -1,65 +0,0 @@ -import asyncio -import random - -from loguru import logger - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.birthday_service import Birthday -from services.config_service import GuildConfig - - -async def daily_birthday_check(client): - logger.info(CONST.STRINGS["birthday_check_started"]) - birthdays_today = Birthday.get_birthdays_today() - processed_birthdays = 0 - failed_birthdays = 0 - - if birthdays_today: - for user_id, guild_id in birthdays_today: - try: - guild = await client.fetch_guild(guild_id) - member = await guild.fetch_member(user_id) - guild_config = GuildConfig(guild.id) - - if not guild_config.birthday_channel_id: - logger.debug( - CONST.STRINGS["birthday_check_skipped"].format(guild.id), - ) - continue - - message = random.choice(CONST.BIRTHDAY_MESSAGES) - embed = EmbedBuilder.create_success_embed( - None, - author_text="Happy Birthday!", - description=message.format(member.name), - show_name=False, - ) - embed.set_image(url=CONST.BIRTHDAY_GIF_URL) - - channel = await guild.fetch_channel(guild_config.birthday_channel_id) - await channel.send(embed=embed, content=member.mention) - logger.debug( - CONST.STRINGS["birthday_check_success"].format( - member.id, - guild.id, - channel.id, - ), - ) - processed_birthdays += 1 - - except Exception as e: - logger.warning( - CONST.STRINGS["birthday_check_error"].format(user_id, guild_id, e), - ) - failed_birthdays += 1 - - # wait one second to avoid rate limits - await asyncio.sleep(1) - - logger.info( - CONST.STRINGS["birthday_check_finished"].format( - processed_birthdays, - failed_birthdays, - ), - ) diff --git a/modules/config/__init__.py b/modules/config/__init__.py index b2cff77..e69de29 100644 --- a/modules/config/__init__.py +++ b/modules/config/__init__.py @@ -1,174 +0,0 @@ -import discord -from discord.commands import SlashCommandGroup -from discord.ext import bridge, commands -from discord.ext.commands import guild_only - -from modules.config import ( - c_birthday, - c_boost, - c_greet, - c_level, - c_moderation, - c_prefix, - c_show, - xp_reward, -) - - -class Config(commands.Cog): - def __init__(self, client): - self.client = client - - @bridge.bridge_command( - name="xprewards", - aliases=["xpr"], - description="Show your server's XP rewards list.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - @commands.has_permissions(manage_roles=True) - async def xp_reward_command_show(self, ctx): - await xp_reward.show(ctx) - - @bridge.bridge_command( - name="addxpreward", - aliases=["axpr"], - description="Add a Lumi XP reward.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - @commands.has_permissions(manage_roles=True) - async def xp_reward_command_add( - self, - ctx, - level: int, - role: discord.Role, - persistent: bool = False, - ): - await xp_reward.add_reward(ctx, level, role.id, persistent) - - @bridge.bridge_command( - name="removexpreward", - aliases=["rxpr"], - description="Remove a Lumi XP reward.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - @commands.has_permissions(manage_roles=True) - async def xp_reward_command_remove(self, ctx, level: int): - await xp_reward.remove_reward(ctx, level) - - """ - CONFIG GROUPS - The 'config' group consists of many different configuration types, each being guild-specific and guild-only. - All commands in this group are exclusively available as slash-commands. - Only administrators can access commands in this group. - - - Birthdays - - Welcome - - Boosts - - Levels - - Prefix - - Modlog channel - - Permissions preset (coming soon) - - Running '/config show' will show a list of all available configuration types. - """ - config = SlashCommandGroup( - "config", - "server config commands.", - contexts={discord.InteractionContextType.guild}, - default_member_permissions=discord.Permissions(administrator=True), - ) - - @config.command(name="show") - async def config_command(self, ctx): - await c_show.cmd(ctx) - - birthday_config = config.create_subgroup(name="birthdays") - - @birthday_config.command(name="channel") - async def config_birthdays_channel(self, ctx, channel: discord.TextChannel): - await c_birthday.set_birthday_channel(ctx, channel) - - @birthday_config.command(name="disable") - async def config_birthdays_disable(self, ctx): - await c_birthday.disable_birthday_module(ctx) - - welcome_config = config.create_subgroup(name="greetings") - - @welcome_config.command(name="channel") - async def config_welcome_channel(self, ctx, channel: discord.TextChannel): - await c_greet.set_welcome_channel(ctx, channel) - - @welcome_config.command(name="disable") - async def config_welcome_disable(self, ctx): - await c_greet.disable_welcome_module(ctx) - - @welcome_config.command(name="template") - @discord.commands.option(name="text", type=str, max_length=2000) - async def config_welcome_template(self, ctx, text): - await c_greet.set_welcome_template(ctx, text) - - boost_config = config.create_subgroup(name="boosts") - - @boost_config.command(name="channel") - async def config_boosts_channel(self, ctx, channel: discord.TextChannel): - await c_boost.set_boost_channel(ctx, channel) - - @boost_config.command(name="disable") - async def config_boosts_disable(self, ctx): - await c_boost.disable_boost_module(ctx) - - @boost_config.command(name="template") - @discord.commands.option(name="text", type=str, max_length=2000) - async def config_boosts_template(self, ctx, text): - await c_boost.set_boost_template(ctx, text) - - @boost_config.command(name="image") - @discord.commands.option(name="url", type=str, max_length=2000) - async def config_boosts_image(self, ctx, url): - await c_boost.set_boost_image(ctx, url) - - level_config = config.create_subgroup(name="levels") - - @level_config.command(name="channel") - async def config_level_channel(self, ctx, channel: discord.TextChannel): - await c_level.set_level_channel(ctx, channel) - - @level_config.command(name="currentchannel") - async def config_level_samechannel(self, ctx): - await c_level.set_level_current_channel(ctx) - - @level_config.command(name="disable") - async def config_level_disable(self, ctx): - await c_level.disable_level_module(ctx) - - @level_config.command(name="enable") - async def config_level_enable(self, ctx): - await c_level.enable_level_module(ctx) - - @level_config.command(name="type") - @discord.commands.option(name="type", choices=["whimsical", "generic"]) - async def config_level_type(self, ctx, type): - await c_level.set_level_type(ctx, type) - - @level_config.command(name="template") - async def config_level_template(self, ctx, text: str): - await c_level.set_level_template(ctx, text) - - prefix_config = config.create_subgroup(name="prefix") - - @prefix_config.command(name="set") - async def config_prefix_set(self, ctx, prefix: str): - await c_prefix.set_prefix(ctx, prefix) - - modlog = config.create_subgroup(name="moderation") - - @modlog.command(name="log") - async def config_moderation_log_channel(self, ctx, channel: discord.TextChannel): - await c_moderation.set_mod_log_channel(ctx, channel) - - -def setup(client): - client.add_cog(Config(client)) diff --git a/modules/config/c_birthday.py b/modules/config/c_birthday.py deleted file mode 100644 index 5a74f22..0000000 --- a/modules/config/c_birthday.py +++ /dev/null @@ -1,43 +0,0 @@ -import discord - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.config_service import GuildConfig - - -async def set_birthday_channel(ctx, channel: discord.TextChannel): - guild_config = GuildConfig(ctx.guild.id) - guild_config.birthday_channel_id = channel.id - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_birthday_channel_set"].format( - channel.mention, - ), - ) - - return await ctx.respond(embed=embed) - - -async def disable_birthday_module(ctx): - guild_config = GuildConfig(ctx.guild.id) - - if not guild_config.birthday_channel_id: - embed = EmbedBuilder().create_warning_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_birthday_module_already_disabled"], - ) - - else: - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_birthday_module_disabled"], - ) - guild_config.birthday_channel_id = None - guild_config.push() - - return await ctx.respond(embed=embed) diff --git a/modules/config/c_boost.py b/modules/config/c_boost.py deleted file mode 100644 index ff0c271..0000000 --- a/modules/config/c_boost.py +++ /dev/null @@ -1,125 +0,0 @@ -import discord - -import lib.formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException -from services.config_service import GuildConfig - - -async def set_boost_channel(ctx, channel: discord.TextChannel): - guild_config = GuildConfig(ctx.guild.id) - guild_config.boost_channel_id = channel.id - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_boost_channel_set"].format(channel.mention), - ) - - return await ctx.respond(embed=embed) - - -async def disable_boost_module(ctx): - guild_config = GuildConfig(ctx.guild.id) - - if not guild_config.boost_channel_id: - embed = EmbedBuilder().create_warning_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_boost_module_already_disabled"], - ) - else: - guild_config.boost_channel_id = None - guild_config.boost_message = None - guild_config.push() - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_boost_module_disabled"], - ) - - return await ctx.respond(embed=embed) - - -async def set_boost_template(ctx, text: str): - guild_config = GuildConfig(ctx.guild.id) - guild_config.boost_message = text - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_boost_template_updated"], - footer_text=CONST.STRINGS["config_example_next_footer"], - ) - embed.add_field( - name=CONST.STRINGS["config_boost_template_field"], - value=f"```{text}```", - inline=False, - ) - - await ctx.respond(embed=embed) - - example_embed = create_boost_embed(ctx.author, text, guild_config.boost_image_url) - return await ctx.send(embed=example_embed, content=ctx.author.mention) - - -async def set_boost_image(ctx, image_url: str | None): - guild_config = GuildConfig(ctx.guild.id) - - if image_url is None or image_url.lower() == "original": - guild_config.boost_image_url = None - guild_config.push() - image_url = None - elif not image_url.endswith(CONST.ALLOWED_IMAGE_EXTENSIONS): - raise LumiException(CONST.STRINGS["error_boost_image_url_invalid"]) - elif not image_url.startswith(("http://", "https://")): - raise LumiException(CONST.STRINGS["error_image_url_invalid"]) - else: - guild_config.boost_image_url = image_url - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_boost_image_updated"], - footer_text=CONST.STRINGS["config_example_next_footer"], - ) - embed.add_field( - name=CONST.STRINGS["config_boost_image_field"], - value=image_url or CONST.STRINGS["config_boost_image_original"], - inline=False, - ) - - await ctx.respond(embed=embed) - - example_embed = create_boost_embed( - ctx.author, - guild_config.boost_message, - image_url, - ) - return await ctx.send(embed=example_embed, content=ctx.author.mention) - - -async def create_boost_embed( - member: discord.Member, - template: str | None = None, - image_url: str | None = None, -): - embed = discord.Embed( - color=discord.Color.nitro_pink(), - title=CONST.STRINGS["boost_default_title"], - description=CONST.STRINGS["boost_default_description"].format(member.name), - ) - - if template: - embed.description = lib.formatter.template(template, member.name) - - embed.set_author(name=member.name, icon_url=member.display_avatar) - embed.set_image(url=image_url or CONST.BOOST_ICON) - embed.set_footer( - text=f"Total server boosts: {member.guild.premium_subscription_count}", - icon_url=CONST.EXCLAIM_ICON, - ) diff --git a/modules/config/c_greet.py b/modules/config/c_greet.py deleted file mode 100644 index 547703c..0000000 --- a/modules/config/c_greet.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import Optional - -import discord -from discord.ext.commands import MemberConverter - -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException -from services.config_service import GuildConfig - - -async def set_welcome_channel(ctx, channel: discord.TextChannel) -> None: - if not ctx.guild: - raise LumiException() - - guild_config: GuildConfig = GuildConfig(ctx.guild.id) - guild_config.welcome_channel_id = channel.id - guild_config.push() - - embed: discord.Embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_welcome_channel_set"].format(channel.mention), - ) - - await ctx.respond(embed=embed) - - -async def disable_welcome_module(ctx) -> None: - guild_config: GuildConfig = GuildConfig(ctx.guild.id) - - if not guild_config.welcome_channel_id: - embed: discord.Embed = EmbedBuilder().create_warning_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_welcome_module_already_disabled"], - ) - else: - guild_config.welcome_channel_id = None - guild_config.welcome_message = None - guild_config.push() - embed: discord.Embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_welcome_module_disabled"], - ) - - await ctx.respond(embed=embed) - - -async def set_welcome_template(ctx, text: str) -> None: - if not ctx.guild: - raise LumiException() - - guild_config: GuildConfig = GuildConfig(ctx.guild.id) - guild_config.welcome_message = text - guild_config.push() - - embed: discord.Embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_welcome_template_updated"], - footer_text=CONST.STRINGS["config_example_next_footer"], - ) - embed.add_field( - name=CONST.STRINGS["config_welcome_template_field"], - value=f"```{text}```", - inline=False, - ) - - await ctx.respond(embed=embed) - - greet_member: discord.Member = await MemberConverter().convert(ctx, str(ctx.author)) - example_embed: discord.Embed = create_greet_embed(greet_member, text) - await ctx.send(embed=example_embed, content=ctx.author.mention) - - -def create_greet_embed( - member: discord.Member, - template: Optional[str] = None, -) -> discord.Embed: - embed: discord.Embed = discord.Embed( - color=discord.Color.embed_background(), - description=CONST.STRINGS["greet_default_description"].format( - member.guild.name, - ), - ) - if template and embed.description is not None: - embed.description += CONST.STRINGS["greet_template_description"].format( - formatter.template(template, member.name), - ) - - embed.set_thumbnail(url=member.display_avatar.url) - - return embed diff --git a/modules/config/c_level.py b/modules/config/c_level.py deleted file mode 100644 index c9c1bef..0000000 --- a/modules/config/c_level.py +++ /dev/null @@ -1,137 +0,0 @@ -import discord - -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.config_service import GuildConfig - - -async def set_level_channel(ctx, channel: discord.TextChannel): - guild_config = GuildConfig(ctx.guild.id) - guild_config.level_channel_id = channel.id - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_channel_set"].format(channel.mention), - ) - - if guild_config.level_message_type == 0: - embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) - - return await ctx.respond(embed=embed) - - -async def set_level_current_channel(ctx): - guild_config = GuildConfig(ctx.guild.id) - guild_config.level_channel_id = None - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_current_channel_set"], - ) - - if guild_config.level_message_type == 0: - embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) - - return await ctx.respond(embed=embed) - - -async def disable_level_module(ctx): - guild_config = GuildConfig(ctx.guild.id) - guild_config.level_message_type = 0 - guild_config.push() - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_module_disabled"], - ) - - return await ctx.respond(embed=embed) - - -async def enable_level_module(ctx): - guild_config = GuildConfig(ctx.guild.id) - - if guild_config.level_message_type != 0: - embed = EmbedBuilder().create_info_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_module_already_enabled"], - ) - else: - guild_config.level_message_type = 1 - guild_config.push() - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_module_enabled"], - ) - - return await ctx.respond(embed=embed) - - -async def set_level_type(ctx, type: str): - guild_config = GuildConfig(ctx.guild.id) - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - ) - - guild_config.level_message = None - if type == "whimsical": - guild_config.level_message_type = 1 - guild_config.push() - - embed.description = CONST.STRINGS["config_level_type_whimsical"] - embed.add_field( - name=CONST.STRINGS["config_level_type_example"], - value=CONST.STRINGS["config_level_type_whimsical_example"], - inline=False, - ) - else: - guild_config.level_message_type = 2 - guild_config.push() - - embed.description = CONST.STRINGS["config_level_type_generic"] - embed.add_field( - name=CONST.STRINGS["config_level_type_example"], - value=CONST.STRINGS["config_level_type_generic_example"], - inline=False, - ) - - return await ctx.respond(embed=embed) - - -async def set_level_template(ctx, text: str): - guild_config = GuildConfig(ctx.guild.id) - guild_config.level_message = text - guild_config.push() - - preview = formatter.template(text, "Lucas", 15) - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_level_template_updated"], - ) - embed.add_field( - name=CONST.STRINGS["config_level_template"], - value=f"```{text}```", - inline=False, - ) - embed.add_field( - name=CONST.STRINGS["config_level_type_example"], - value=preview, - inline=False, - ) - - if guild_config.level_message_type == 0: - embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) - - return await ctx.respond(embed=embed) diff --git a/modules/config/c_moderation.py b/modules/config/c_moderation.py deleted file mode 100644 index d9003d0..0000000 --- a/modules/config/c_moderation.py +++ /dev/null @@ -1,44 +0,0 @@ -import discord - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException -from services.moderation.modlog_service import ModLogService - - -async def set_mod_log_channel(ctx, channel: discord.TextChannel): - mod_log = ModLogService() - - info_embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_modlog_info_author"], - description=CONST.STRINGS["config_modlog_info_description"].format( - ctx.guild.name, - ), - show_name=False, - ) - info_embed.add_field( - name=CONST.STRINGS["config_modlog_info_commands_name"], - value=CONST.STRINGS["config_modlog_info_commands_value"], - inline=False, - ) - info_embed.add_field( - name=CONST.STRINGS["config_modlog_info_warning_name"], - value=CONST.STRINGS["config_modlog_info_warning_value"], - inline=False, - ) - - try: - await channel.send(embed=info_embed) - except discord.errors.Forbidden as e: - raise LumiException(CONST.STRINGS["config_modlog_permission_error"]) from e - - mod_log.set_modlog_channel(ctx.guild.id, channel.id) - - success_embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_modlog_channel_set"].format(channel.mention), - ) - - return await ctx.respond(embed=success_embed) diff --git a/modules/config/c_prefix.py b/modules/config/c_prefix.py deleted file mode 100644 index 655c520..0000000 --- a/modules/config/c_prefix.py +++ /dev/null @@ -1,35 +0,0 @@ -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.config_service import GuildConfig - - -async def set_prefix(ctx, prefix): - if len(prefix) > 25: - embed = EmbedBuilder().create_error_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_prefix_too_long"], - ) - return await ctx.respond(embed=embed) - - guild_config = GuildConfig( - ctx.guild.id, - ) # generate a guild_config for if it didn't already exist - GuildConfig.set_prefix(guild_config.guild_id, prefix) - - embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_prefix_set"].format(prefix), - ) - await ctx.respond(embed=embed) - - -async def get_prefix(ctx): - prefix = GuildConfig.get_prefix_from_guild_id(ctx.guild.id) if ctx.guild else "." - embed = EmbedBuilder().create_info_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_author"], - description=CONST.STRINGS["config_prefix_get"].format(prefix), - ) - await ctx.respond(embed=embed) diff --git a/modules/config/c_show.py b/modules/config/c_show.py deleted file mode 100644 index c19f9ed..0000000 --- a/modules/config/c_show.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import List, Tuple, Optional - -import discord -from discord import Guild - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.config_service import GuildConfig -from services.moderation.modlog_service import ModLogService - - -async def cmd(ctx) -> None: - guild_config: GuildConfig = GuildConfig(ctx.guild.id) - guild: Guild = ctx.guild - embed: discord.Embed = EmbedBuilder().create_success_embed( - ctx=ctx, - author_text=CONST.STRINGS["config_show_author"].format(guild.name), - thumbnail_url=guild.icon.url if guild.icon else CONST.LUMI_LOGO_TRANSPARENT, - show_name=False, - ) - - config_items: List[Tuple[str, bool, bool]] = [ - ( - CONST.STRINGS["config_show_birthdays"], - bool(guild_config.birthday_channel_id), - False, - ), - ( - CONST.STRINGS["config_show_new_member_greets"], - bool(guild_config.welcome_channel_id), - False, - ), - ( - CONST.STRINGS["config_show_boost_announcements"], - bool(guild_config.boost_channel_id), - False, - ), - ( - CONST.STRINGS["config_show_level_announcements"], - guild_config.level_message_type != 0, - False, - ), - ] - - for name, enabled, default_enabled in config_items: - status: str = ( - CONST.STRINGS["config_show_enabled"] - if enabled - else CONST.STRINGS["config_show_disabled"] - ) - if not enabled and default_enabled: - status = CONST.STRINGS["config_show_default_enabled"] - embed.add_field(name=name, value=status, inline=False) - - modlog_service: ModLogService = ModLogService() - modlog_channel_id: Optional[int] = modlog_service.fetch_modlog_channel_id(guild.id) - modlog_channel = guild.get_channel(modlog_channel_id) if modlog_channel_id else None - - modlog_status: str - if modlog_channel: - modlog_status = CONST.STRINGS["config_show_moderation_log_enabled"].format( - modlog_channel.mention, - ) - elif modlog_channel_id: - modlog_status = CONST.STRINGS["config_show_moderation_log_channel_deleted"] - else: - modlog_status = CONST.STRINGS["config_show_moderation_log_not_configured"] - - embed.add_field( - name=CONST.STRINGS["config_show_moderation_log"], - value=modlog_status, - inline=False, - ) - - await ctx.respond(embed=embed) diff --git a/modules/config/config.py b/modules/config/config.py new file mode 100644 index 0000000..0761363 --- /dev/null +++ b/modules/config/config.py @@ -0,0 +1,797 @@ +import discord +from discord import app_commands +from discord.ext import commands + +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException +from services.config_service import GuildConfig +from services.modlog_service import ModLogService +from services.xp_service import XpRewardService +from ui.config import create_boost_embed, create_greet_embed +from ui.embeds import Builder + + +@app_commands.guild_only() +@app_commands.default_permissions(administrator=True) +class Config(commands.GroupCog, group_name="config"): + def __init__(self, bot: commands.Bot): + self.bot = bot + + birthdays = app_commands.Group(name="birthdays", description="Configure the birthdays module") + boosts = app_commands.Group(name="boosts", description="Configure the boosts module") + greets = app_commands.Group(name="greets", description="Configure the greets module") + levels = app_commands.Group(name="levels", description="Configure the levels module") + moderation = app_commands.Group(name="moderation", description="Configure the moderation module") + prefix = app_commands.Group(name="prefix", description="Configure the prefix for the bot") + xpreward = app_commands.Group(name="xpreward", description="Configure the xp reward for the bot") + + @app_commands.command(name="show") + async def config_help(self, interaction: discord.Interaction) -> None: + """ + Show the current configuration for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to show the config for. + """ + assert interaction.guild + guild_config: GuildConfig = GuildConfig(interaction.guild.id) + guild: discord.Guild = interaction.guild + embed: discord.Embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_show_author"].format(guild.name), + thumbnail_url=guild.icon.url if guild.icon else CONST.LUMI_LOGO_TRANSPARENT, + hide_name_in_description=True, + ) + + config_items: list[tuple[str, bool, bool]] = [ + ( + CONST.STRINGS["config_show_birthdays"], + bool(guild_config.birthday_channel_id), + False, + ), + ( + CONST.STRINGS["config_show_new_member_greets"], + bool(guild_config.welcome_channel_id), + False, + ), + ( + CONST.STRINGS["config_show_boost_announcements"], + bool(guild_config.boost_channel_id), + False, + ), + ( + CONST.STRINGS["config_show_level_announcements"], + guild_config.level_message_type != 0, + False, + ), + ] + + for name, enabled, default_enabled in config_items: + status: str = CONST.STRINGS["config_show_enabled"] if enabled else CONST.STRINGS["config_show_disabled"] + if not enabled and default_enabled: + status = CONST.STRINGS["config_show_default_enabled"] + embed.add_field(name=name, value=status, inline=False) + + modlog_service: ModLogService = ModLogService() + modlog_channel_id: int | None = modlog_service.fetch_modlog_channel_id(guild.id) + modlog_channel = guild.get_channel(modlog_channel_id) if modlog_channel_id else None + + modlog_status: str + if modlog_channel: + modlog_status = CONST.STRINGS["config_show_moderation_log_enabled"].format( + modlog_channel.mention, + ) + elif modlog_channel_id: + modlog_status = CONST.STRINGS["config_show_moderation_log_channel_deleted"] + else: + modlog_status = CONST.STRINGS["config_show_moderation_log_not_configured"] + + embed.add_field( + name=CONST.STRINGS["config_show_moderation_log"], + value=modlog_status, + inline=False, + ) + + await interaction.response.send_message(embed=embed) + + @birthdays.command(name="channel") + async def birthday_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """ + Set the birthday channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the birthday channel for. + channel : discord.TextChannel + The channel to set as the birthday channel. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.birthday_channel_id = channel.id + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_birthday_channel_set"].format( + channel.mention, + ), + ) + + await interaction.response.send_message(embed=embed) + + @birthdays.command(name="disable") + async def birthday_disable(self, interaction: discord.Interaction) -> None: + """ + Disable the birthday module for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to disable the birthday module for. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + + if not guild_config.birthday_channel_id: + embed = Builder.create_embed( + theme="warning", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_birthday_module_already_disabled"], + ) + + else: + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_birthday_module_disabled"], + ) + guild_config.birthday_channel_id = None + guild_config.push() + + await interaction.response.send_message(embed=embed) + + @levels.command(name="channel") + async def set_level_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """ + Set the level-up announcement channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the level-up announcement channel for. + channel : discord.TextChannel + The channel to set as the level-up announcement channel. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.level_channel_id = channel.id + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_channel_set"].format(channel.mention), + ) + + if guild_config.level_message_type == 0: + embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) + + await interaction.response.send_message(embed=embed) + + @boosts.command(name="channel") + async def set_boost_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """ + Set the boost announcement channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the boost announcement channel for. + channel : discord.TextChannel + The channel to set as the boost announcement channel. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.boost_channel_id = channel.id + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_boost_channel_set"].format(channel.mention), + ) + + await interaction.response.send_message(embed=embed) + + @boosts.command(name="disable") + async def disable_boost_module(self, interaction: discord.Interaction) -> None: + """ + Disable the boost module for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to disable the boost module for. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + + if not guild_config.boost_channel_id: + embed = Builder.create_embed( + theme="warning", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_boost_module_already_disabled"], + ) + else: + guild_config.boost_channel_id = None + guild_config.boost_message = None + guild_config.push() + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_boost_module_disabled"], + ) + + await interaction.response.send_message(embed=embed) + + @boosts.command(name="template") + async def set_boost_template(self, interaction: discord.Interaction, text: str) -> None: + """ + Set the boost message template for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the boost message template for. + text : str + The template text to set for boost messages. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.boost_message = text + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_boost_template_updated"], + footer_text=CONST.STRINGS["config_example_next_footer"], + ) + embed.add_field( + name=CONST.STRINGS["config_boost_template_field"], + value=f"```{text}```", + inline=False, + ) + + await interaction.response.send_message(embed=embed) + + example_embed = create_boost_embed( + user_name=interaction.user.name, + user_avatar_url=interaction.user.display_avatar.url, + boost_count=interaction.guild.premium_subscription_count, + template=text, + image_url=guild_config.boost_image_url, + ) + await interaction.followup.send(embed=example_embed, content=interaction.user.mention) + + @boosts.command(name="image") + async def set_boost_image(self, interaction: discord.Interaction, image_url: str | None) -> None: + """ + Set the boost message image for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the boost message image for. + image_url : str | None + The image URL to set for boost messages. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + + if image_url is None or image_url.lower() == "original": + guild_config.boost_image_url = None + guild_config.push() + image_url = None + elif not image_url.endswith(tuple(CONST.ALLOWED_IMAGE_EXTENSIONS)): + raise ValueError(CONST.STRINGS["error_boost_image_url_invalid"]) + elif not image_url.startswith(("http://", "https://")): + raise ValueError(CONST.STRINGS["error_image_url_invalid"]) + else: + guild_config.boost_image_url = image_url + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_boost_image_updated"], + footer_text=CONST.STRINGS["config_example_next_footer"], + ) + embed.add_field( + name=CONST.STRINGS["config_boost_image_field"], + value=image_url or CONST.STRINGS["config_boost_image_original"], + inline=False, + ) + + await interaction.response.send_message(embed=embed) + + example_embed = create_boost_embed( + user_name=interaction.user.name, + user_avatar_url=interaction.user.display_avatar.url, + boost_count=interaction.guild.premium_subscription_count, + template=guild_config.boost_message, + image_url=image_url, + ) + await interaction.followup.send(embed=example_embed, content=interaction.user.mention) + + @greets.command(name="channel") + async def set_welcome_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """ + Set the welcome channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the welcome channel for. + channel : discord.TextChannel + The channel to set as the welcome channel. + """ + assert interaction.guild + guild_config: GuildConfig = GuildConfig(interaction.guild.id) + guild_config.welcome_channel_id = channel.id + guild_config.push() + + embed: discord.Embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_welcome_channel_set"].format(channel.mention), + ) + + await interaction.response.send_message(embed=embed) + + @greets.command(name="disable") + async def disable_welcome_module(self, interaction: discord.Interaction) -> None: + """ + Disable the welcome module for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to disable the welcome module for. + """ + assert interaction.guild + guild_config: GuildConfig = GuildConfig(interaction.guild.id) + + if not guild_config.welcome_channel_id: + embed: discord.Embed = Builder.create_embed( + theme="warning", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_welcome_module_already_disabled"], + ) + else: + guild_config.welcome_channel_id = None + guild_config.welcome_message = None + guild_config.push() + embed: discord.Embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_welcome_module_disabled"], + ) + + await interaction.response.send_message(embed=embed) + + @greets.command(name="template") + async def set_welcome_template(self, interaction: discord.Interaction, text: str) -> None: + """ + Set the welcome message template for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the welcome message template for. + text : str + The welcome message template. + """ + assert interaction.guild + guild_config: GuildConfig = GuildConfig(interaction.guild.id) + guild_config.welcome_message = text + guild_config.push() + + embed: discord.Embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_welcome_template_updated"], + footer_text=CONST.STRINGS["config_example_next_footer"], + ) + embed.add_field( + name=CONST.STRINGS["config_welcome_template_field"], + value=f"```{text}```", + inline=False, + ) + + await interaction.response.send_message(embed=embed) + + example_embed: discord.Embed = create_greet_embed( + user_name=interaction.user.name, + user_avatar_url=interaction.user.display_avatar.url, + guild_name=interaction.guild.name, + template=text, + ) + + await interaction.followup.send(embed=example_embed, content=interaction.user.mention) + + @levels.command(name="current_channel") + async def set_level_current_channel(self, interaction: discord.Interaction) -> None: + """ + Set the current channel as the level-up announcement channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the current channel as the level-up announcement channel for. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.level_channel_id = None + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_current_channel_set"], + ) + + if guild_config.level_message_type == 0: + embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) + + await interaction.response.send_message(embed=embed) + + @levels.command(name="disable") + async def disable_level_module(self, interaction: discord.Interaction) -> None: + """ + Disable the level-up module for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to disable the level-up module for. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.level_message_type = 0 + guild_config.push() + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_module_disabled"], + ) + + await interaction.response.send_message(embed=embed) + + @levels.command(name="enable") + async def enable_level_module(self, interaction: discord.Interaction) -> None: + """ + Enable the level-up module for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to enable the level-up module for. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + + if guild_config.level_message_type != 0: + embed = Builder.create_embed( + theme="info", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_module_already_enabled"], + ) + else: + guild_config.level_message_type = 1 + guild_config.push() + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_module_enabled"], + ) + + await interaction.response.send_message(embed=embed) + + @levels.command(name="template") + async def set_level_template(self, interaction: discord.Interaction, text: str) -> None: + """ + Set the template for level-up messages for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the template for level-up messages for. + text : str + The template text to set for level-up messages. + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + guild_config.level_message = text + guild_config.push() + + preview = lib.format.template(text, "Lucas", 15) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_level_template_updated"], + ) + embed.add_field( + name=CONST.STRINGS["config_level_template"], + value=f"```{text}```", + inline=False, + ) + embed.add_field( + name=CONST.STRINGS["config_level_type_example"], + value=preview, + inline=False, + ) + + if guild_config.level_message_type == 0: + embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"]) + + await interaction.response.send_message(embed=embed) + + @levels.command(name="type") + async def set_level_type(self, interaction: discord.Interaction, level_type: str) -> None: + """ + Set the type of level-up messages for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the type of level-up messages for. + level_type : str + The type of level-up messages to set (e.g., "whimsical" or "generic"). + """ + assert interaction.guild + guild_config = GuildConfig(interaction.guild.id) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + ) + + guild_config.level_message = None + if level_type == "whimsical": + guild_config.level_message_type = 1 + guild_config.push() + + embed.description = CONST.STRINGS["config_level_type_whimsical"] + embed.add_field( + name=CONST.STRINGS["config_level_type_example"], + value=CONST.STRINGS["config_level_type_whimsical_example"], + inline=False, + ) + else: + guild_config.level_message_type = 2 + guild_config.push() + + embed.description = CONST.STRINGS["config_level_type_generic"] + embed.add_field( + name=CONST.STRINGS["config_level_type_example"], + value=CONST.STRINGS["config_level_type_generic_example"], + inline=False, + ) + + await interaction.response.send_message(embed=embed) + + @moderation.command(name="log") + async def set_mod_log_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None: + """ + Set the moderation log channel for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the moderation log channel for. + channel : discord.TextChannel + The channel to set as the moderation log channel. + """ + assert interaction.guild + mod_log = ModLogService() + + info_embed = Builder.create_embed( + theme="info", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_modlog_info_author"], + description=CONST.STRINGS["config_modlog_info_description"].format( + interaction.guild.name, + ), + hide_name_in_description=True, + ) + info_embed.add_field( + name=CONST.STRINGS["config_modlog_info_commands_name"], + value=CONST.STRINGS["config_modlog_info_commands_value"], + inline=False, + ) + info_embed.add_field( + name=CONST.STRINGS["config_modlog_info_warning_name"], + value=CONST.STRINGS["config_modlog_info_warning_value"], + inline=False, + ) + + try: + await channel.send(embed=info_embed) + except discord.errors.Forbidden as e: + raise LumiException(CONST.STRINGS["config_modlog_permission_error"]) from e + + mod_log.set_modlog_channel(interaction.guild.id, channel.id) + + success_embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_modlog_channel_set"].format(channel.mention), + ) + + await interaction.response.send_message(embed=success_embed) + + @prefix.command(name="set") + async def set_prefix(self, interaction: discord.Interaction, prefix: str) -> None: + """ + Set the prefix for the bot in the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to set the prefix for. + prefix : str + The prefix to set for the bot. + """ + assert interaction.guild + if len(prefix) > 25: + embed = Builder.create_embed( + theme="error", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_prefix_too_long"], + ) + await interaction.response.send_message(embed=embed) + return + + guild_config = GuildConfig(interaction.guild.id) + GuildConfig.set_prefix(guild_config.guild_id, prefix) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_prefix_set"].format(prefix), + ) + + await interaction.response.send_message(embed=embed) + + @xpreward.command(name="show") + async def show_xpreward(self, interaction: discord.Interaction) -> None: + """ + Show the current XP rewards for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to show the XP rewards for. + """ + assert interaction.guild + level_reward = XpRewardService(interaction.guild.id) + + embed = Builder.create_embed( + theme="info", + user_name=interaction.user.name, + author_text="Level Rewards", + thumbnail_url=interaction.guild.icon.url if interaction.guild.icon else CONST.LUMI_LOGO_OPAQUE, + hide_name_in_description=True, + ) + + if not level_reward.rewards: + embed.description = CONST.STRINGS["config_xpreward_show_no_rewards"] + else: + for level in sorted(level_reward.rewards.keys()): + role_id, persistent = level_reward.rewards[level] + role = interaction.guild.get_role(role_id) + + if embed.description is None: + embed.description = "" + + embed.description += f"\n**Level {level}** -> {role.mention if role else 'Role not found'}" + + if persistent: + embed.description += " (persistent)" + + await interaction.response.send_message(embed=embed) + + @xpreward.command(name="add") + async def add_xpreward( + self, + interaction: discord.Interaction, + level: int, + role: discord.Role, + persistent: bool, + ) -> None: + """ + Add an XP reward for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to add the XP reward for. + level : int + The level to add the reward for. + role : discord.Role + The role to assign as a reward. + persistent : bool + Whether the reward is persistent. + """ + assert interaction.guild + level_reward = XpRewardService(interaction.guild.id) + level_reward.add_reward(level, role.id, persistent) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_xpreward_added"].format(level, role.mention), + ) + + await interaction.response.send_message(embed=embed) + + @xpreward.command(name="remove") + async def remove_xpreward(self, interaction: discord.Interaction, level: int) -> None: + """ + Remove an XP reward for the server. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to remove the XP reward for. + level : int + The level to remove the reward for. + """ + assert interaction.guild + level_reward = XpRewardService(interaction.guild.id) + level_reward.remove_reward(level) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["config_author"], + description=CONST.STRINGS["config_xpreward_removed"].format(level), + ) + + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Config(bot)) diff --git a/modules/config/xp_reward.py b/modules/config/xp_reward.py deleted file mode 100644 index bb9eafd..0000000 --- a/modules/config/xp_reward.py +++ /dev/null @@ -1,43 +0,0 @@ -import discord - -from lib.constants import CONST -from services.xp_service import XpRewardService - - -async def show(ctx): - level_reward = XpRewardService(ctx.guild.id) - - embed = discord.Embed( - color=discord.Color.embed_background(), - description="", - ) - - icon = ctx.guild.icon or CONST.LUMI_LOGO_OPAQUE - embed.set_author(name="Level Rewards", icon_url=icon) - for level in sorted(level_reward.rewards.keys()): - role_id, persistent = level_reward.rewards[level] - role = ctx.guild.get_role(role_id) - - if embed.description is None: - embed.description = "" - - embed.description += ( - f"\n**Level {level}** -> {role.mention if role else 'Role not found'}" - ) - - if persistent: - embed.description += " (persistent)" - - await ctx.respond(embed=embed) - - -async def add_reward(ctx, level, role_id, persistent): - level_reward = XpRewardService(ctx.guild.id) - level_reward.add_reward(level, role_id, persistent) - await show(ctx) - - -async def remove_reward(ctx, level): - level_reward = XpRewardService(ctx.guild.id) - level_reward.remove_reward(level) - await show(ctx) diff --git a/modules/economy/__init__.py b/modules/economy/__init__.py index 9bc7237..e69de29 100644 --- a/modules/economy/__init__.py +++ b/modules/economy/__init__.py @@ -1,77 +0,0 @@ -import discord -from discord.ext import bridge, commands -from discord.ext.commands import guild_only - -from modules.economy import balance, blackjack, daily, give, slots - - -class Economy(commands.Cog): - def __init__(self, client): - self.client = client - - @bridge.bridge_command( - name="balance", - aliases=["bal", "$"], - description="Shows your current Lumi balance.", - help="Shows your current Lumi balance. The economy system is global, meaning your balance will be synced in " - "all servers.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - @commands.cooldown(1, 10, commands.BucketType.user) - async def balance_command(self, ctx): - return await balance.cmd(ctx) - - @bridge.bridge_command( - name="blackjack", - aliases=["bj"], - description="Start a game of blackjack.", - help="Start a game of blackjack.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - async def blackjack_command(self, ctx, *, bet: int): - return await blackjack.cmd(ctx, bet) - - @bridge.bridge_command( - name="daily", - aliases=["timely"], - description="Claim your daily reward.", - help="Claim your daily reward! Reset is at 7 AM EST.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - async def daily_command(self, ctx): - return await daily.cmd(ctx) - - @commands.slash_command( - name="give", - description="Give a server member some cash.", - contexts={discord.InteractionContextType.guild}, - ) - async def give_command(self, ctx, *, user: discord.Member, amount: int): - return await give.cmd(ctx, user, amount) - - @commands.command( - name="give", - help="Give a server member some cash. You can use ID or mention them.", - ) - @guild_only() - async def give_command_prefixed(self, ctx, user: discord.User, *, amount: int): - return await give.cmd(ctx, user, amount) - - @bridge.bridge_command( - name="slots", - aliases=["slot"], - description="Start a slots game.", - help="Spin the slots for a chance to win the jackpot!", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - @commands.cooldown(1, 5, commands.BucketType.user) - async def slots_command(self, ctx, *, bet: int): - return await slots.cmd(self, ctx, bet) - - -def setup(client): - client.add_cog(Economy(client)) diff --git a/modules/economy/balance.py b/modules/economy/balance.py index fb04840..c2d365d 100644 --- a/modules/economy/balance.py +++ b/modules/economy/balance.py @@ -1,22 +1,50 @@ from discord.ext import commands +import lib.format +from lib.const import CONST from services.currency_service import Currency -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +from ui.embeds import Builder -async def cmd(ctx: commands.Context[commands.Bot]) -> None: - ctx_currency = Currency(ctx.author.id) - balance = Currency.format(ctx_currency.balance) +class Balance(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.balance.usage = lib.format.generate_usage(self.balance) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["balance_author"].format(ctx.author.name), - author_icon_url=ctx.author.display_avatar.url, - description=CONST.STRINGS["balance_cash"].format(balance), - footer_text=CONST.STRINGS["balance_footer"], - show_name=False, - hide_timestamp=True, + @commands.hybrid_command( + name="balance", + aliases=["bal", "$"], ) + @commands.guild_only() + async def balance( + self, + ctx: commands.Context[commands.Bot], + ) -> None: + """ + Check your current balance. - await ctx.respond(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + + ctx_currency = Currency(ctx.author.id) + balance = Currency.format(ctx_currency.balance) + + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["balance_author"].format(ctx.author.name), + author_icon_url=ctx.author.display_avatar.url, + description=CONST.STRINGS["balance_cash"].format(balance), + footer_text=CONST.STRINGS["balance_footer"], + hide_name_in_description=True, + hide_time=True, + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Balance(bot)) diff --git a/modules/economy/blackjack.py b/modules/economy/blackjack.py index bbc8c56..61197bb 100644 --- a/modules/economy/blackjack.py +++ b/modules/economy/blackjack.py @@ -1,324 +1,303 @@ import random -from typing import List, Tuple -from loguru import logger +from zoneinfo import ZoneInfo import discord -from discord.ui import View -import pytz from discord.ext import commands +from loguru import logger -from lib.constants import CONST -from lib.exceptions.LumiExceptions import LumiException +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException from services.currency_service import Currency from services.stats_service import BlackJackStats -from lib.embed_builder import EmbedBuilder +from ui.embeds import Builder +from ui.views.blackjack import BlackJackButtons -EST = pytz.timezone("US/Eastern") +EST = ZoneInfo("US/Eastern") ACTIVE_BLACKJACK_GAMES: dict[int, bool] = {} Card = str -Hand = List[Card] +Hand = list[Card] -async def cmd(ctx: commands.Context, bet: int) -> None: - if ctx.author.id in ACTIVE_BLACKJACK_GAMES: - raise LumiException(CONST.STRINGS["error_already_playing_blackjack"]) +class Blackjack(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.blackjack.usage = lib.format.generate_usage(self.blackjack) - currency = Currency(ctx.author.id) - if bet > currency.balance: - raise LumiException(CONST.STRINGS["error_not_enough_cash"]) - if bet <= 0: - raise LumiException(CONST.STRINGS["error_invalid_bet"]) + @commands.hybrid_command( + name="blackjack", + aliases=["bj"], + ) + @commands.guild_only() + async def blackjack( + self, + ctx: commands.Context[commands.Bot], + bet: int, + ) -> None: + """ + Play a game of blackjack. - ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + bet : int + The amount to bet. + """ + if ctx.author.id in ACTIVE_BLACKJACK_GAMES: + raise LumiException(CONST.STRINGS["error_already_playing_blackjack"]) - try: - await play_blackjack(ctx, currency, bet) - except Exception as e: - logger.exception(f"Error in blackjack game: {e}") - raise LumiException(CONST.STRINGS["error_blackjack_game_error"]) from e - finally: - del ACTIVE_BLACKJACK_GAMES[ctx.author.id] + currency = Currency(ctx.author.id) + if bet > currency.balance: + raise LumiException(CONST.STRINGS["error_not_enough_cash"]) + if bet <= 0: + raise LumiException(CONST.STRINGS["error_invalid_bet"]) + ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True -async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) -> None: - deck = get_new_deck() - player_hand, dealer_hand = initial_deal(deck) - multiplier = CONST.BLACKJACK_MULTIPLIER + try: + await self.play_blackjack(ctx, currency, bet) + except Exception as e: + logger.exception(f"Error in blackjack game: {e}") + raise LumiException(CONST.STRINGS["error_blackjack_game_error"]) from e + finally: + del ACTIVE_BLACKJACK_GAMES[ctx.author.id] - player_value = calculate_hand_value(player_hand) - status = 5 if player_value == 21 else 0 - view = BlackJackButtons(ctx) - playing_embed = False + async def play_blackjack(self, ctx: commands.Context[commands.Bot], currency: Currency, bet: int) -> None: + deck = self.get_new_deck() + player_hand, dealer_hand = self.initial_deal(deck) + multiplier = CONST.BLACKJACK_MULTIPLIER - while status == 0: - dealer_value = calculate_hand_value(dealer_hand) + player_value = self.calculate_hand_value(player_hand) + status = 5 if player_value == 21 else 0 + view = BlackJackButtons(ctx) + playing_embed = False + response_message: discord.Message | None = None - embed = create_game_embed( + while status == 0: + dealer_value = self.calculate_hand_value(dealer_hand) + + embed = self.create_game_embed( + ctx, + bet, + player_hand, + dealer_hand, + player_value, + dealer_value, + ) + if not playing_embed: + response_message = await ctx.reply(embed=embed, view=view) + playing_embed = True + else: + assert response_message + await response_message.edit(embed=embed, view=view) + + await view.wait() + + if view.clickedHit: + player_hand.append(self.deal_card(deck)) + player_value = self.calculate_hand_value(player_hand) + if player_value > 21: + status = 1 + break + if player_value == 21: + status = 2 + break + elif view.clickedStand: + status = self.dealer_play(deck, dealer_hand, player_value) + break + else: + currency.take_balance(bet) + currency.push() + raise LumiException(CONST.STRINGS["error_out_of_time_economy"]) + + view = BlackJackButtons(ctx) + + await self.handle_game_end( ctx, + response_message, + currency, bet, player_hand, dealer_hand, - player_value, - dealer_value, + status, + multiplier, + playing_embed, ) - if not playing_embed: - await ctx.respond(embed=embed, view=view, content=ctx.author.mention) - playing_embed = True + + def initial_deal(self, deck: list[Card]) -> tuple[Hand, Hand]: + return [self.deal_card(deck) for _ in range(2)], [self.deal_card(deck)] + + def dealer_play(self, deck: list[Card], dealer_hand: Hand, player_value: int) -> int: + while self.calculate_hand_value(dealer_hand) <= player_value: + dealer_hand.append(self.deal_card(deck)) + return 3 if self.calculate_hand_value(dealer_hand) > 21 else 4 + + async def handle_game_end( + self, + ctx: commands.Context[commands.Bot], + response_message: discord.Message | None, + currency: Currency, + bet: int, + player_hand: Hand, + dealer_hand: Hand, + status: int, + multiplier: float, + playing_embed: bool, + ) -> None: + player_value = self.calculate_hand_value(player_hand) + dealer_value = self.calculate_hand_value(dealer_hand) + payout = bet * (2 if status == 5 else multiplier) + is_won = status not in [1, 4] + + embed = self.create_end_game_embed(ctx, bet, player_value, dealer_value, payout, status) + if playing_embed: + assert response_message + await response_message.edit(embed=embed) else: - await ctx.edit(embed=embed, view=view) + await ctx.reply(embed=embed) - await view.wait() - - if view.clickedHit: - player_hand.append(deal_card(deck)) - player_value = calculate_hand_value(player_hand) - if player_value > 21: - status = 1 - break - elif player_value == 21: - status = 2 - break - elif view.clickedStand: - status = dealer_play(deck, dealer_hand, player_value) - break + if is_won: + currency.add_balance(int(payout)) else: currency.take_balance(bet) - currency.push() - raise LumiException(CONST.STRINGS["error_out_of_time_economy"]) + currency.push() - view = BlackJackButtons(ctx) + BlackJackStats( + user_id=ctx.author.id, + is_won=is_won, + bet=bet, + payout=int(payout) if is_won else 0, + hand_player=player_hand, + hand_dealer=dealer_hand, + ).push() - await handle_game_end( - ctx, - currency, - bet, - player_hand, - dealer_hand, - status, - multiplier, - playing_embed, - ) - - -def initial_deal(deck: List[Card]) -> Tuple[Hand, Hand]: - return [deal_card(deck) for _ in range(2)], [deal_card(deck)] - - -def dealer_play(deck: List[Card], dealer_hand: Hand, player_value: int) -> int: - while calculate_hand_value(dealer_hand) <= player_value: - dealer_hand.append(deal_card(deck)) - return 3 if calculate_hand_value(dealer_hand) > 21 else 4 - - -async def handle_game_end( - ctx: commands.Context, - currency: Currency, - bet: int, - player_hand: Hand, - dealer_hand: Hand, - status: int, - multiplier: float, - playing_embed: bool, -) -> None: - player_value = calculate_hand_value(player_hand) - dealer_value = calculate_hand_value(dealer_hand) - payout = bet * (2 if status == 5 else multiplier) - is_won = status not in [1, 4] - - embed = create_end_game_embed(ctx, bet, player_value, dealer_value, payout, status) - - if playing_embed: - await ctx.edit(embed=embed, view=None) - else: - await ctx.respond(embed=embed, view=None, content=ctx.author.mention) - - currency.add_balance(payout) if is_won else currency.take_balance(bet) - currency.push() - - BlackJackStats( - user_id=ctx.author.id, - is_won=is_won, - bet=bet, - payout=payout if is_won else 0, - hand_player=player_hand, - hand_dealer=dealer_hand, - ).push() - - -def create_game_embed( - ctx: commands.Context, - bet: int, - player_hand: Hand, - dealer_hand: Hand, - player_value: int, - dealer_value: int, -) -> discord.Embed: - player_hand_str = " + ".join(player_hand) - dealer_hand_str = f"{dealer_hand[0]} + " + ( - CONST.STRINGS["blackjack_dealer_hidden"] - if len(dealer_hand) < 2 - else " + ".join(dealer_hand[1:]) - ) - - description = ( - f"{CONST.STRINGS['blackjack_player_hand'].format(player_value, player_hand_str)}\n\n" - f"{CONST.STRINGS['blackjack_dealer_hand'].format(dealer_value, dealer_hand_str)}" - ) - - footer_text = ( - f"{CONST.STRINGS['blackjack_bet'].format(Currency.format_human(bet))} • " - f"{CONST.STRINGS['blackjack_deck_shuffled']}" - ) - - return EmbedBuilder.create_embed( - ctx, - title=CONST.STRINGS["blackjack_title"], - color=discord.Colour.embed_background(), - description=description, - footer_text=footer_text, - footer_icon_url=CONST.MUFFIN_ART, - show_name=False, - hide_timestamp=True, - ) - - -def create_end_game_embed( - ctx: commands.Context, - bet: int, - player_value: int, - dealer_value: int, - payout: int, - status: int, -) -> discord.Embed: - embed = EmbedBuilder.create_embed( - ctx, - title=CONST.STRINGS["blackjack_title"], - color=discord.Colour.embed_background(), - description=CONST.STRINGS["blackjack_description"].format( - player_value, - dealer_value, - ), - footer_text=CONST.STRINGS["blackjack_footer"], - footer_icon_url=CONST.MUFFIN_ART, - show_name=False, - ) - - result = { - 1: ( - CONST.STRINGS["blackjack_busted"], - CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)), - discord.Color.red(), - CONST.CLOUD_ART, - ), - 2: ( - CONST.STRINGS["blackjack_won_21"], - CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)), - discord.Color.green(), - CONST.TROPHY_ART, - ), - 3: ( - CONST.STRINGS["blackjack_dealer_busted"], - CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)), - discord.Color.green(), - CONST.TROPHY_ART, - ), - 4: ( - CONST.STRINGS["blackjack_lost_generic"], - CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)), - discord.Color.red(), - CONST.CLOUD_ART, - ), - 5: ( - CONST.STRINGS["blackjack_won_natural"], - CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)), - discord.Color.green(), - CONST.TROPHY_ART, - ), - }.get( - status, - ( - CONST.STRINGS["blackjack_error"], - CONST.STRINGS["blackjack_error_description"], - discord.Color.red(), - None, - ), - ) - - name, value, color, thumbnail_url = result - embed.add_field(name=name, value=value, inline=False) - embed.colour = color - if thumbnail_url: - embed.set_thumbnail(url=thumbnail_url) - - return embed - - -def get_new_deck() -> List[Card]: - deck = [ - rank + suit - for suit in ["♠", "♡", "♢", "♣"] - for rank in ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] - ] - random.shuffle(deck) - return deck - - -def deal_card(deck: List[Card]) -> Card: - return deck.pop() - - -def calculate_hand_value(hand: Hand) -> int: - value = sum( - 10 if rank in "JQK" else 11 if rank == "A" else int(rank) - for card in hand - for rank in card[:-1] - ) - aces = sum(card[0] == "A" for card in hand) - while value > 21 and aces: - value -= 10 - aces -= 1 - return value - - -class BlackJackButtons(View): - def __init__(self, ctx): - super().__init__(timeout=180) - self.ctx = ctx - self.clickedHit = False - self.clickedStand = False - self.clickedDoubleDown = False - - async def on_timeout(self): - for child in self.children: - child.disabled = True - await self.message.edit(view=None) - - @discord.ui.button( - label=CONST.STRINGS["blackjack_hit"], - style=discord.ButtonStyle.gray, - emoji=CONST.BLACKJACK_HIT_EMOJI, - ) - async def hit_button_callback(self, button, interaction): - self.clickedHit = True - await interaction.response.defer() - self.stop() - - @discord.ui.button( - label=CONST.STRINGS["blackjack_stand"], - style=discord.ButtonStyle.gray, - emoji=CONST.BLACKJACK_STAND_EMOJI, - ) - async def stand_button_callback(self, button, interaction): - self.clickedStand = True - await interaction.response.defer() - self.stop() - - async def interaction_check(self, interaction) -> bool: - if interaction.user == self.ctx.author: - return True - await interaction.response.send_message( - CONST.STRINGS["error_cant_use_buttons"], - ephemeral=True, + def create_game_embed( + self, + ctx: commands.Context[commands.Bot], + bet: int, + player_hand: Hand, + dealer_hand: Hand, + player_value: int, + dealer_value: int, + ) -> discord.Embed: + player_hand_str = " + ".join(player_hand) + dealer_hand_str = f"{dealer_hand[0]} + " + ( + CONST.STRINGS["blackjack_dealer_hidden"] if len(dealer_hand) < 2 else " + ".join(dealer_hand[1:]) ) - return False + + description = ( + f"{CONST.STRINGS['blackjack_player_hand'].format(player_value, player_hand_str)}\n\n" + f"{CONST.STRINGS['blackjack_dealer_hand'].format(dealer_value, dealer_hand_str)}" + ) + + footer_text = ( + f"{CONST.STRINGS['blackjack_bet'].format(Currency.format_human(bet))} • " + f"{CONST.STRINGS['blackjack_deck_shuffled']}" + ) + + return Builder.create_embed( + theme="default", + user_name=ctx.author.name, + title=CONST.STRINGS["blackjack_title"], + description=description, + footer_text=footer_text, + footer_icon_url=CONST.MUFFIN_ART, + hide_name_in_description=True, + ) + + def create_end_game_embed( + self, + ctx: commands.Context[commands.Bot], + bet: int, + player_value: int, + dealer_value: int, + payout: int | float, + status: int, + ) -> discord.Embed: + embed = Builder.create_embed( + theme="default", + user_name=ctx.author.name, + title=CONST.STRINGS["blackjack_title"], + description=CONST.STRINGS["blackjack_description"].format( + player_value, + dealer_value, + ), + footer_text=CONST.STRINGS["blackjack_footer"], + footer_icon_url=CONST.MUFFIN_ART, + hide_name_in_description=True, + ) + + result = { + 1: ( + CONST.STRINGS["blackjack_busted"], + CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)), + discord.Color.red(), + CONST.CLOUD_ART, + ), + 2: ( + CONST.STRINGS["blackjack_won_21"], + CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))), + discord.Color.green(), + CONST.TROPHY_ART, + ), + 3: ( + CONST.STRINGS["blackjack_dealer_busted"], + CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))), + discord.Color.green(), + CONST.TROPHY_ART, + ), + 4: ( + CONST.STRINGS["blackjack_lost_generic"], + CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)), + discord.Color.red(), + CONST.CLOUD_ART, + ), + 5: ( + CONST.STRINGS["blackjack_won_natural"], + CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))), + discord.Color.green(), + CONST.TROPHY_ART, + ), + }.get( + status, + ( + CONST.STRINGS["blackjack_error"], + CONST.STRINGS["blackjack_error_description"], + discord.Color.red(), + None, + ), + ) + + name, value, color, thumbnail_url = result + embed.add_field(name=name, value=value, inline=False) + embed.colour = color + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) + + return embed + + def get_new_deck(self) -> list[Card]: + deck = [ + rank + suit + for suit in ["♠", "♡", "♢", "♣"] + for rank in ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] + ] + random.shuffle(deck) + return deck + + def deal_card(self, deck: list[Card]) -> Card: + return deck.pop() + + def calculate_hand_value(self, hand: Hand) -> int: + value = sum(10 if rank in "JQK" else 11 if rank == "A" else int(rank) for card in hand for rank in card[:-1]) + aces = sum(card[0] == "A" for card in hand) + while value > 21 and aces: + value -= 10 + aces -= 1 + return value + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Blackjack(bot)) diff --git a/modules/economy/daily.py b/modules/economy/daily.py index b7777a4..bf5833f 100644 --- a/modules/economy/daily.py +++ b/modules/economy/daily.py @@ -1,43 +1,85 @@ from datetime import datetime, timedelta +from zoneinfo import ZoneInfo -import lib.time -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +from discord import Embed +from discord.ext import commands + +import lib.format +from lib.const import CONST from services.currency_service import Currency from services.daily_service import Dailies +from ui.embeds import Builder + +tz = ZoneInfo("US/Eastern") -async def cmd(ctx) -> None: - ctx_daily = Dailies(ctx.author.id) +def seconds_until(hours: int, minutes: int) -> int: + now = datetime.now(tz) + given_time = now.replace(hour=hours, minute=minutes, second=0, microsecond=0) - if not ctx_daily.can_be_claimed(): - wait_time = datetime.now() + timedelta(seconds=lib.time.seconds_until(7, 0)) - unix_time = int(round(wait_time.timestamp())) - error_embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["daily_already_claimed_author"], - description=CONST.STRINGS["daily_already_claimed_description"].format( - unix_time, - ), - footer_text=CONST.STRINGS["daily_already_claimed_footer"], - ) - await ctx.respond(embed=error_embed) - return - ctx_daily.streak = ctx_daily.streak + 1 if ctx_daily.streak_check() else 1 - ctx_daily.claimed_at = datetime.now(tz=ctx_daily.tz) - ctx_daily.amount = 100 * 12 * (ctx_daily.streak - 1) + if given_time < now: + given_time += timedelta(days=1) - ctx_daily.refresh() + return int((given_time - now).total_seconds()) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["daily_success_claim_author"], - description=CONST.STRINGS["daily_success_claim_description"].format( - Currency.format(ctx_daily.amount), - ), - footer_text=CONST.STRINGS["daily_streak_footer"].format(ctx_daily.streak) - if ctx_daily.streak > 1 - else None, + +class Daily(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.daily.usage = lib.format.generate_usage(self.daily) + + @commands.hybrid_command( + name="daily", + aliases=["timely"], ) + @commands.guild_only() + async def daily( + self, + ctx: commands.Context[commands.Bot], + ) -> None: + """ + Claim your daily reward. - await ctx.respond(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + ctx_daily: Dailies = Dailies(ctx.author.id) + + if not ctx_daily.can_be_claimed(): + wait_time: datetime = datetime.now(tz) + timedelta(seconds=seconds_until(7, 0)) + unix_time: int = int(round(wait_time.timestamp())) + error_embed: Embed = Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["daily_already_claimed_author"], + description=CONST.STRINGS["daily_already_claimed_description"].format( + unix_time, + ), + footer_text=CONST.STRINGS["daily_already_claimed_footer"], + ) + await ctx.send(embed=error_embed) + return + + ctx_daily.streak = ctx_daily.streak + 1 if ctx_daily.streak_check() else 1 + ctx_daily.claimed_at = datetime.now(tz=ctx_daily.tz) + ctx_daily.amount = 100 * 12 * (ctx_daily.streak - 1) + + ctx_daily.refresh() + + embed: Embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["daily_success_claim_author"], + description=CONST.STRINGS["daily_success_claim_description"].format( + Currency.format(ctx_daily.amount), + ), + footer_text=CONST.STRINGS["daily_streak_footer"].format(ctx_daily.streak) if ctx_daily.streak > 1 else None, + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Daily(bot)) diff --git a/modules/economy/give.py b/modules/economy/give.py index 07ffdeb..05f9ea5 100644 --- a/modules/economy/give.py +++ b/modules/economy/give.py @@ -1,39 +1,70 @@ import discord from discord.ext import commands +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException from services.currency_service import Currency -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException +from ui.embeds import Builder -async def cmd(ctx: commands.Context, user: discord.User, amount: int) -> None: - if ctx.author.id == user.id: - raise LumiException(CONST.STRINGS["give_error_self"]) - if user.bot: - raise LumiException(CONST.STRINGS["give_error_bot"]) - if amount <= 0: - raise LumiException(CONST.STRINGS["give_error_invalid_amount"]) +class Give(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.give.usage = lib.format.generate_usage(self.give) - ctx_currency = Currency(ctx.author.id) - target_currency = Currency(user.id) - - if ctx_currency.balance < amount: - raise LumiException(CONST.STRINGS["give_error_insufficient_funds"]) - - target_currency.add_balance(amount) - ctx_currency.take_balance(amount) - - ctx_currency.push() - target_currency.push() - - embed = EmbedBuilder.create_success_embed( - ctx, - description=CONST.STRINGS["give_success"].format( - ctx.author.name, - Currency.format(amount), - user.name, - ), + @commands.hybrid_command( + name="give", ) + @commands.guild_only() + async def give( + self, + ctx: commands.Context[commands.Bot], + user: discord.User, + amount: int, + ) -> None: + """ + Give currency to another user. - await ctx.respond(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + user : discord.User + The user to give currency to. + amount : int + The amount of currency to give. + """ + if ctx.author.id == user.id: + raise LumiException(CONST.STRINGS["give_error_self"]) + if user.bot: + raise LumiException(CONST.STRINGS["give_error_bot"]) + if amount <= 0: + raise LumiException(CONST.STRINGS["give_error_invalid_amount"]) + + ctx_currency = Currency(ctx.author.id) + target_currency = Currency(user.id) + + if ctx_currency.balance < amount: + raise LumiException(CONST.STRINGS["give_error_insufficient_funds"]) + + target_currency.add_balance(amount) + ctx_currency.take_balance(amount) + + ctx_currency.push() + target_currency.push() + + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + description=CONST.STRINGS["give_success"].format( + Currency.format(amount), + user.name, + ), + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Give(bot)) diff --git a/modules/economy/slots.py b/modules/economy/slots.py index 3ab2b87..c55f1f6 100644 --- a/modules/economy/slots.py +++ b/modules/economy/slots.py @@ -2,213 +2,254 @@ import asyncio import datetime import random from collections import Counter +from zoneinfo import ZoneInfo import discord -import pytz from discord.ext import commands -from lib.constants import CONST +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException from services.currency_service import Currency from services.stats_service import SlotsStats -est = pytz.timezone("US/Eastern") +est = ZoneInfo("US/Eastern") -async def cmd(self, ctx, bet): - ctx_currency = Currency(ctx.author.id) +class Slots(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.slots.usage = lib.format.generate_usage(self.slots) - player_balance = ctx_currency.balance - if bet > player_balance: - raise commands.BadArgument("you don't have enough cash.") - elif bet <= 0: - raise commands.BadArgument("the bet you entered is invalid.") - - results = [random.randint(0, 6) for _ in range(3)] - calculated_results = calculate_slots_results(bet, results) - - (result_type, payout, multiplier) = calculated_results - is_won = result_type != "lost" - # only get the emojis once - emojis = get_emotes(self.client) - - # start with default "spinning" embed - await ctx.respond( - embed=slots_spinning(ctx, 3, Currency.format_human(bet), results, emojis), + @commands.hybrid_command( + name="slots", + aliases=["slot"], ) - await asyncio.sleep(1) + @commands.guild_only() + async def slots( + self, + ctx: commands.Context[commands.Bot], + bet: int, + ) -> None: + """ + Play the slots machine. - for i in range(2, 0, -1): - await ctx.edit( - embed=slots_spinning(ctx, i, Currency.format_human(bet), results, emojis), + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + bet : int + The amount to bet. + """ + ctx_currency: Currency = Currency(ctx.author.id) + + player_balance: int = ctx_currency.balance + if bet > player_balance: + raise LumiException(CONST.STRINGS["error_not_enough_cash"]) + if bet <= 0: + raise LumiException(CONST.STRINGS["error_invalid_bet"]) + + results: list[int] = [random.randint(0, 6) for _ in range(3)] + calculated_results: tuple[str, int, float] = self.calculate_slots_results(bet, results) + + result_type, payout, _ = calculated_results + is_won: bool = result_type != "lost" + emojis: dict[str, discord.Emoji | None] = self.get_emotes() + + await ctx.defer() + + message: discord.Message = await ctx.reply( + embed=self.slots_spinning(ctx, 3, Currency.format_human(bet), results, emojis), ) + + for i in range(2, 0, -1): + await asyncio.sleep(1) + await message.edit( + embed=self.slots_spinning(ctx, i, Currency.format_human(bet), results, emojis), + ) + + # output final result + finished_output: discord.Embed = self.slots_finished( + ctx, + result_type, + Currency.format_human(bet), + Currency.format_human(payout), + results, + emojis, + ) + await asyncio.sleep(1) + await message.edit(embed=finished_output) - # output final result - finished_output = slots_finished( - ctx, - result_type, - Currency.format_human(bet), - Currency.format_human(payout), - results, - emojis, - ) - - await ctx.edit(embed=finished_output) - - # user payout - if payout > 0: - ctx_currency.add_balance(payout) - else: - ctx_currency.take_balance(bet) - - stats = SlotsStats( - user_id=ctx.author.id, - is_won=is_won, - bet=bet, - payout=payout, - spin_type=result_type, - icons=results, - ) - - ctx_currency.push() - stats.push() - - -def get_emotes(client): - emotes = CONST.EMOTE_IDS - return {name: client.get_emoji(emoji_id) for name, emoji_id in emotes.items()} - - -def calculate_slots_results(bet, results): - result_type = None - multiplier = None - rewards = CONST.SLOTS_MULTIPLIERS - - # count occurrences of each item in the list - counts = Counter(results) - - # no icons match - if len(counts) == 3: - result_type = "lost" - multiplier = 0 - - elif len(counts) == 2: - result_type = "pair" - multiplier = rewards[result_type] - - elif len(counts) == 1: - if results[0] == 5: - result_type = "three_diamonds" - elif results[0] == 6: - result_type = "jackpot" + # user payout + if payout > 0: + ctx_currency.add_balance(payout) else: - result_type = "three_of_a_kind" - multiplier = rewards[result_type] + ctx_currency.take_balance(bet) - payout = bet * multiplier - return result_type, int(payout), multiplier - - -def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis): - first_slots_emote = emojis.get(f"slots_{results[0]}_id") - second_slots_emote = emojis.get(f"slots_{results[1]}_id") - slots_animated_emote = emojis.get("slots_animated_id") - - current_time = datetime.datetime.now(est).strftime("%I:%M %p") - one = slots_animated_emote - two = slots_animated_emote - three = slots_animated_emote - - if spinning_icons_amount == 3: - pass - elif spinning_icons_amount == 2: - one = first_slots_emote - elif spinning_icons_amount == 1: - one = first_slots_emote - two = second_slots_emote - - description = ( - f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n" - f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}" - f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n" - f"{emojis['VBorder']}{one}{emojis['VBorder']}{two}{emojis['VBorder']}" - f"{three}{emojis['VBorder']}\n" - f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}" - f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}\n" - f"{emojis['Blank']}{emojis['Blank']}❓❓❓{emojis['Blank']}{emojis['Blank']}{emojis['Blank']}" - ) - - embed = discord.Embed( - description=description, - ) - embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url) - embed.set_footer( - text=f"Bet ${bet} • jackpot = x15 • {current_time}", - icon_url="https://i.imgur.com/wFsgSnr.png", - ) - - return embed - - -def slots_finished(ctx, payout_type, bet, payout, results, emojis): - first_slots_emote = emojis.get(f"slots_{results[0]}_id") - second_slots_emote = emojis.get(f"slots_{results[1]}_id") - third_slots_emote = emojis.get(f"slots_{results[2]}_id") - current_time = datetime.datetime.now(est).strftime("%I:%M %p") - - field_name = "You lost." - field_value = f"You lost **${bet}**." - color = discord.Color.red() - is_lost = True - - if payout_type == "pair": - field_name = "Pair" - field_value = f"You won **${payout}**." - is_lost = False - color = discord.Color.dark_green() - elif payout_type == "three_of_a_kind": - field_name = "3 of a kind" - field_value = f"You won **${payout}**." - is_lost = False - color = discord.Color.dark_green() - elif payout_type == "three_diamonds": - field_name = "Triple Diamonds!" - field_value = f"You won **${payout}**." - is_lost = False - color = discord.Color.green() - elif payout_type == "jackpot": - field_name = "JACKPOT!!" - field_value = f"You won **${payout}**." - is_lost = False - color = discord.Color.green() - - description = ( - f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n" - f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}" - f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n" - f"{emojis['VBorder']}{first_slots_emote}{emojis['VBorder']}{second_slots_emote}" - f"{emojis['VBorder']}{third_slots_emote}{emojis['VBorder']}\n" - f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}" - f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}" - ) - - if is_lost: - description += ( - f"\n{emojis['Blank']}{emojis['LCentered']}{emojis['OCentered']}{emojis['SCentered']}" - f"{emojis['ECentered']}{emojis['lost']}{emojis['Blank']}" + stats: SlotsStats = SlotsStats( + user_id=ctx.author.id, + is_won=is_won, + bet=bet, + payout=payout, + spin_type=result_type, + icons=[str(icon) for icon in results], ) - else: - description += f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}" - embed = discord.Embed( - color=color, - description=description, - ) - embed.add_field(name=field_name, value=field_value, inline=False) - embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url) - embed.set_footer( - text=f"Game finished • {current_time}", - icon_url="https://i.imgur.com/wFsgSnr.png", - ) + ctx_currency.push() + stats.push() - return embed + def get_emotes(self) -> dict[str, discord.Emoji | None]: + emotes: dict[str, int] = CONST.EMOTE_IDS + return {name: self.bot.get_emoji(emoji_id) for name, emoji_id in emotes.items()} + + def calculate_slots_results(self, bet: int, results: list[int]) -> tuple[str, int, float]: + result_type: str = "lost" + multiplier: float = 0.0 + rewards: dict[str, float] = CONST.SLOTS_MULTIPLIERS + + # count occurrences of each item in the list + counts: Counter[int] = Counter(results) + + # no icons match + if len(counts) == 3: + result_type = "lost" + multiplier = 0.0 + + elif len(counts) == 2: + result_type = "pair" + multiplier = rewards[result_type] + + elif len(counts) == 1: + if results[0] == 5: + result_type = "three_diamonds" + elif results[0] == 6: + result_type = "jackpot" + else: + result_type = "three_of_a_kind" + multiplier = rewards[result_type] + + payout: int = int(bet * multiplier) + return result_type, payout, multiplier + + def slots_spinning( + self, + ctx: commands.Context[commands.Bot], + spinning_icons_amount: int, + bet: str, + results: list[int], + emojis: dict[str, discord.Emoji | None], + ) -> discord.Embed: + first_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[0]}_id") + second_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[1]}_id") + slots_animated_emote: discord.Emoji | None = emojis.get("slots_animated_id") + + current_time: str = datetime.datetime.now(est).strftime("%I:%M %p") + one: discord.Emoji | None = slots_animated_emote + two: discord.Emoji | None = slots_animated_emote + three: discord.Emoji | None = slots_animated_emote + + if spinning_icons_amount == 1: + one = first_slots_emote + two = second_slots_emote + + elif spinning_icons_amount == 2: + one = first_slots_emote + description: str = ( + f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n" + f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}" + f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n" + f"{emojis['VBorder']}{one}{emojis['VBorder']}{two}{emojis['VBorder']}" + f"{three}{emojis['VBorder']}\n" + f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}" + f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}\n" + f"{emojis['Blank']}{emojis['Blank']}❓❓❓{emojis['Blank']}{emojis['Blank']}{emojis['Blank']}" + ) + + embed: discord.Embed = discord.Embed( + description=description, + ) + embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None) + embed.set_footer( + text=f"Bet ${bet} • jackpot = x15 • {current_time}", + icon_url="https://i.imgur.com/wFsgSnr.png", + ) + + return embed + + def slots_finished( + self, + ctx: commands.Context[commands.Bot], + payout_type: str, + bet: str, + payout: str, + results: list[int], + emojis: dict[str, discord.Emoji | None], + ) -> discord.Embed: + first_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[0]}_id") + second_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[1]}_id") + third_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[2]}_id") + current_time: str = datetime.datetime.now(est).strftime("%I:%M %p") + + field_name: str = "You lost." + field_value: str = f"You lost **${bet}**." + color: discord.Color = discord.Color.red() + is_lost: bool = True + + if payout_type == "pair": + field_name = "Pair" + field_value = f"You won **${payout}**." + is_lost = False + color = discord.Color.dark_green() + elif payout_type == "three_of_a_kind": + field_name = "3 of a kind" + field_value = f"You won **${payout}**." + is_lost = False + color = discord.Color.dark_green() + elif payout_type == "three_diamonds": + field_name = "Triple Diamonds!" + field_value = f"You won **${payout}**." + is_lost = False + color = discord.Color.green() + elif payout_type == "jackpot": + field_name = "JACKPOT!!" + field_value = f"You won **${payout}**." + is_lost = False + color = discord.Color.green() + + description: str = ( + f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n" + f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}" + f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n" + f"{emojis['VBorder']}{first_slots_emote}{emojis['VBorder']}{second_slots_emote}" + f"{emojis['VBorder']}{third_slots_emote}{emojis['VBorder']}\n" + f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}" + f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}" + ) + + if is_lost: + description += ( + f"\n{emojis['Blank']}{emojis['LCentered']}{emojis['OCentered']}{emojis['SCentered']}" + f"{emojis['ECentered']}{emojis['lost']}{emojis['Blank']}" + ) + else: + description += ( + f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}" + ) + + embed: discord.Embed = discord.Embed( + color=color, + description=description, + ) + embed.add_field(name=field_name, value=field_value, inline=False) + embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None) + embed.set_footer( + text=f"Game finished • {current_time}", + icon_url="https://i.imgur.com/wFsgSnr.png", + ) + + return embed + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Slots(bot)) diff --git a/modules/help/__init__.py b/modules/help/__init__.py deleted file mode 100644 index 1957e2f..0000000 --- a/modules/help/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from discord.ext import commands - -from lib import constants, embed_builder, formatter - - -class Help(commands.Cog): - def __init__(self, client: commands.Bot) -> None: - self.client = client - - @commands.slash_command( - name="help", - description="Get Lumi help.", - ) - async def help_command(self, ctx) -> None: - prefix = formatter.get_prefix(ctx) - embed = embed_builder.EmbedBuilder.create_warning_embed( - ctx=ctx, - description=constants.CONST.STRINGS["help_use_prefix"].format(prefix), - ) - await ctx.respond(embed=embed, ephemeral=True) - - -def setup(client: commands.Bot) -> None: - client.add_cog(Help(client)) diff --git a/modules/levels/__init__.py b/modules/levels/__init__.py index 8a4d488..e69de29 100644 --- a/modules/levels/__init__.py +++ b/modules/levels/__init__.py @@ -1,36 +0,0 @@ -import discord -from discord.ext import bridge, commands -from discord.ext.commands import guild_only - -from modules.levels import leaderboard, level - - -class Levels(commands.Cog): - def __init__(self, client: commands.Bot) -> None: - self.client = client - - @bridge.bridge_command( - name="level", - aliases=["rank", "xp"], - description="Displays your level and server rank.", - help="Displays your level and server rank.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - async def level_command(self, ctx) -> None: - await level.rank(ctx) - - @bridge.bridge_command( - name="leaderboard", - aliases=["lb", "xplb"], - description="See the Lumi leaderboards.", - help="Shows three different leaderboards: levels, currency and daily streaks.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - async def leaderboard_command(self, ctx) -> None: - await leaderboard.cmd(ctx) - - -def setup(client: commands.Bot) -> None: - client.add_cog(Levels(client)) diff --git a/modules/levels/leaderboard.py b/modules/levels/leaderboard.py index 381f26b..c1f7813 100644 --- a/modules/levels/leaderboard.py +++ b/modules/levels/leaderboard.py @@ -1,202 +1,52 @@ -from datetime import datetime +from typing import cast -import discord -from discord.ext import bridge +from discord import Embed, Guild, Member +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.currency_service import Currency -from services.daily_service import Dailies -from services.xp_service import XpService +import lib.format +from lib.const import CONST +from ui.embeds import Builder +from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView -async def cmd(ctx: bridge.Context) -> None: - if not ctx.guild: - return +class Leaderboard(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.leaderboard.usage = lib.format.generate_usage(self.leaderboard) - options = LeaderboardCommandOptions() - view = LeaderboardCommandView(ctx, options) - - # default leaderboard - embed = EmbedBuilder.create_success_embed( - ctx=ctx, - thumbnail_url=CONST.FLOWERS_ART, - show_name=False, + @commands.hybrid_command( + name="leaderboard", + aliases=["lb"], ) + async def leaderboard(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Get the leaderboard for the server. - icon = ctx.guild.icon.url if ctx.guild.icon else CONST.FLOWERS_ART - await view.populate_leaderboard("xp", embed, icon) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + guild: Guild | None = ctx.guild + if not guild: + return - await ctx.respond(embed=embed, view=view) + options: LeaderboardCommandOptions = LeaderboardCommandOptions() + view: LeaderboardCommandView = LeaderboardCommandView(ctx, options) - -class LeaderboardCommandOptions(discord.ui.Select): - """ - This class specifies the options for the leaderboard command: - - XP - - Currency - - Daily streak - """ - - def __init__(self) -> None: - super().__init__( - placeholder="Select a leaderboard", - min_values=1, - max_values=1, - options=[ - discord.SelectOption( - label="Levels", - description="See the top chatters of this server!", - emoji="🆙", - value="xp", - ), - discord.SelectOption( - label="Currency", - description="Who is the richest Lumi user?", - value="currency", - emoji="💸", - ), - discord.SelectOption( - label="Dailies", - description="See who has the biggest streak!", - value="dailies", - emoji="📅", - ), - ], + author: Member = cast(Member, ctx.author) + embed: Embed = Builder.create_embed( + theme="info", + user_name=author.name, + thumbnail_url=author.display_avatar.url, + hide_name_in_description=True, ) - async def callback(self, interaction: discord.Interaction) -> None: - if self.view: - await self.view.on_select(self.values[0], interaction) + icon: str = guild.icon.url if guild.icon else CONST.FLOWERS_ART + await view.populate_leaderboard("xp", embed, icon) + + await ctx.send(embed=embed, view=view) -class LeaderboardCommandView(discord.ui.View): - """ - This view represents a dropdown menu to choose - what kind of leaderboard to show. - """ - - def __init__(self, ctx: bridge.Context, options: LeaderboardCommandOptions) -> None: - self.ctx = ctx - self.options = options - - super().__init__(timeout=180) - self.add_item(self.options) - - async def on_timeout(self) -> None: - if self.message: - await self.message.edit(view=None) - self.stop() - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - if interaction.user and interaction.user != self.ctx.author: - embed = EmbedBuilder.create_error_embed( - ctx=self.ctx, - author_text=interaction.user.name, - description=CONST.STRINGS["xp_lb_cant_use_dropdown"], - show_name=False, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return False - return True - - async def on_select(self, item: str, interaction: discord.Interaction) -> None: - if not self.ctx.guild: - return - - embed = EmbedBuilder.create_success_embed( - ctx=self.ctx, - thumbnail_url=CONST.FLOWERS_ART, - show_name=False, - ) - - icon = self.ctx.guild.icon.url if self.ctx.guild.icon else CONST.FLOWERS_ART - - await self.populate_leaderboard(item, embed, icon) - - await interaction.response.edit_message(embed=embed) - - async def populate_leaderboard(self, item: str, embed, icon): - leaderboard_methods = { - "xp": self._populate_xp_leaderboard, - "currency": self._populate_currency_leaderboard, - "dailies": self._populate_dailies_leaderboard, - } - await leaderboard_methods[item](embed, icon) - - async def _populate_xp_leaderboard(self, embed, icon): - if not self.ctx.guild: - return - - xp_lb = XpService.load_leaderboard(self.ctx.guild.id) - embed.set_author(name=CONST.STRINGS["xp_lb_author"], icon_url=icon) - - for rank, (user_id, xp, level, xp_needed_for_next_level) in enumerate( - xp_lb[:5], - start=1, - ): - try: - member = await self.ctx.guild.fetch_member(user_id) - except discord.HTTPException: - continue # skip user if not in guild - - embed.add_field( - name=CONST.STRINGS["xp_lb_field_name"].format(rank, member.name), - value=CONST.STRINGS["xp_lb_field_value"].format( - level, - xp, - xp_needed_for_next_level, - ), - inline=False, - ) - - async def _populate_currency_leaderboard(self, embed, icon): - if not self.ctx.guild: - return - - cash_lb = Currency.load_leaderboard() - embed.set_author(name=CONST.STRINGS["xp_lb_currency_author"], icon_url=icon) - embed.set_thumbnail(url=CONST.TEAPOT_ART) - - for user_id, balance, rank in cash_lb[:5]: - try: - member = await self.ctx.guild.fetch_member(user_id) - except discord.HTTPException: - member = None - - name = member.name if member else str(user_id) - - embed.add_field( - name=f"#{rank} - {name}", - value=CONST.STRINGS["xp_lb_currency_field_value"].format( - Currency.format(balance), - ), - inline=False, - ) - - async def _populate_dailies_leaderboard(self, embed, icon): - if not self.ctx.guild: - return - - daily_lb = Dailies.load_leaderboard() - embed.set_author(name=CONST.STRINGS["xp_lb_dailies_author"], icon_url=icon) - embed.set_thumbnail(url=CONST.MUFFIN_ART) - - for user_id, streak, claimed_at, rank in daily_lb[:5]: - try: - member = await self.ctx.guild.fetch_member(user_id) - except discord.HTTPException: - member = None - - name = member.name if member else user_id - - claimed_at = datetime.fromisoformat(claimed_at).date() - - embed.add_field( - name=f"#{rank} - {name}", - value=CONST.STRINGS["xp_lb_dailies_field_value"].format( - streak, - claimed_at, - ), - inline=False, - ) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Leaderboard(bot)) diff --git a/modules/levels/level.py b/modules/levels/level.py index e3c4e13..f55da57 100644 --- a/modules/levels/level.py +++ b/modules/levels/level.py @@ -1,31 +1,56 @@ from discord import Embed -from discord.ext import bridge +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +import lib.format +from lib.const import CONST from services.xp_service import XpService +from ui.embeds import Builder -async def rank(ctx: bridge.Context) -> None: - if not ctx.guild: - return +class Level(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.level.usage = lib.format.generate_usage(self.level) - xp_data: XpService = XpService(ctx.author.id, ctx.guild.id) - - rank: str = str(xp_data.calculate_rank()) - needed_xp_for_next_level: int = XpService.xp_needed_for_next_level(xp_data.level) - - embed: Embed = EmbedBuilder.create_success_embed( - ctx=ctx, - title=CONST.STRINGS["xp_level"].format(xp_data.level), - footer_text=CONST.STRINGS["xp_server_rank"].format(rank or "NaN"), - show_name=False, - thumbnail_url=ctx.author.display_avatar.url, - ) - embed.add_field( - name=CONST.STRINGS["xp_progress"], - value=XpService.generate_progress_bar(xp_data.xp, needed_xp_for_next_level), - inline=False, + @commands.hybrid_command( + name="level", + aliases=["rank", "lvl", "xp"], ) + async def level(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Get the level of the user. - await ctx.respond(embed=embed) + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + if not ctx.guild: + return + + xp_data: XpService = XpService(ctx.author.id, ctx.guild.id) + + rank: str = str(xp_data.calculate_rank()) + needed_xp_for_next_level: int = XpService.xp_needed_for_next_level( + xp_data.level, + ) + + embed: Embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + title=CONST.STRINGS["xp_level"].format(xp_data.level), + footer_text=CONST.STRINGS["xp_server_rank"].format(rank or "NaN"), + thumbnail_url=ctx.author.display_avatar.url, + hide_name_in_description=True, + ) + embed.add_field( + name=CONST.STRINGS["xp_progress"], + value=XpService.generate_progress_bar(xp_data.xp, needed_xp_for_next_level), + inline=False, + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Level(bot)) diff --git a/modules/misc/__init__.py b/modules/misc/__init__.py index 77844c3..e69de29 100644 --- a/modules/misc/__init__.py +++ b/modules/misc/__init__.py @@ -1,135 +0,0 @@ -from datetime import datetime - -import discord -from discord.commands import SlashCommandGroup -from discord.ext import bridge, commands, tasks -from discord.ext.commands import guild_only - -from Client import LumiBot -from modules.config import c_prefix -from modules.misc import avatar, backup, info, introduction, invite, ping, xkcd - - -class Misc(commands.Cog): - def __init__(self, client: LumiBot) -> None: - self.client: LumiBot = client - self.start_time: datetime = datetime.now() - self.do_backup.start() - - @tasks.loop(hours=1) - async def do_backup(self) -> None: - await backup.backup() - - @bridge.bridge_command( - name="avatar", - aliases=["av"], - description="Get a user's avatar.", - help="Get a user's avatar.", - contexts={discord.InteractionContextType.guild}, - ) - @guild_only() - async def avatar(self, ctx, user: discord.Member) -> None: - return await avatar.get_avatar(ctx, user) - - @bridge.bridge_command( - name="ping", - aliases=["p", "status"], - description="Simple status check.", - help="Simple status check.", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - async def ping(self, ctx) -> None: - await ping.ping(self, ctx) - - @bridge.bridge_command( - name="uptime", - description="See Lumi's uptime since the last update.", - help="See how long Lumi has been online since his last update.", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - async def uptime(self, ctx) -> None: - await ping.uptime(self, ctx, self.start_time) - - @bridge.bridge_command( - name="invite", - description="Generate an invite link.", - help="Generate a link to invite Lumi to your own server!", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - async def invite_command(self, ctx) -> None: - await invite.cmd(ctx) - - @bridge.bridge_command( - name="prefix", - description="See the server's current prefix.", - help="See the server's current prefix.", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - async def prefix_command(self, ctx) -> None: - await c_prefix.get_prefix(ctx) - - @bridge.bridge_command( - name="info", - aliases=["stats"], - description="Shows basic Lumi stats.", - help="Shows basic Lumi stats.", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - async def info_command(self, ctx) -> None: - unix_timestamp: int = int(round(self.start_time.timestamp())) - await info.cmd(self, ctx, unix_timestamp) - - @bridge.bridge_command( - name="introduction", - aliases=["intro", "introduce"], - description="This command can only be used in DMs.", - help="Introduce yourself. For now this command " - "can only be done in ONE server and only in Lumi's DMs.", - contexts={discord.InteractionContextType.bot_dm}, - ) - @commands.dm_only() - async def intro_command(self, ctx) -> None: - await introduction.cmd(self, ctx) - - """ - xkcd submodule - slash command only - """ - xkcd: SlashCommandGroup = SlashCommandGroup( - "xkcd", - "A web comic of romance, sarcasm, math, and language.", - contexts={ - discord.InteractionContextType.guild, - discord.InteractionContextType.bot_dm, - }, - ) - - @xkcd.command(name="latest", description="Get the latest xkcd comic.") - async def xkcd_latest(self, ctx) -> None: - await xkcd.print_comic(ctx, latest=True) - - @xkcd.command(name="random", description="Get a random xkcd comic.") - async def xkcd_random(self, ctx) -> None: - await xkcd.print_comic(ctx) - - @xkcd.command(name="search", description="Search for a xkcd comic by ID.") - async def xkcd_search(self, ctx, *, id: int) -> None: - await xkcd.print_comic(ctx, number=id) - - -def setup(client: LumiBot) -> None: - client.add_cog(Misc(client)) diff --git a/modules/misc/avatar.py b/modules/misc/avatar.py index 1d8424c..f04221d 100644 --- a/modules/misc/avatar.py +++ b/modules/misc/avatar.py @@ -1,39 +1,11 @@ from io import BytesIO -from typing import Optional +import discord import httpx -from discord import File, Member -from discord.ext import bridge +from discord import File +from discord.ext import commands -client: httpx.AsyncClient = httpx.AsyncClient() - - -async def get_avatar(ctx: bridge.Context, member: Member) -> None: - """ - Get the avatar of a member. - - Parameters: - ----------- - ctx : ApplicationContext - The discord context object. - member : Member - The member to get the avatar of. - """ - guild_avatar: Optional[str] = ( - member.guild_avatar.url if member.guild_avatar else None - ) - profile_avatar: Optional[str] = member.avatar.url if member.avatar else None - - files: list[File] = [ - await create_avatar_file(avatar) - for avatar in [guild_avatar, profile_avatar] - if avatar - ] - - if files: - await ctx.respond(files=files) - else: - await ctx.respond(content="member has no avatar.") +import lib.format async def create_avatar_file(url: str) -> File: @@ -50,9 +22,52 @@ async def create_avatar_file(url: str) -> File: File The discord file. """ + client: httpx.AsyncClient = httpx.AsyncClient() response: httpx.Response = await client.get(url, timeout=10) response.raise_for_status() image_data: bytes = response.content image_file: BytesIO = BytesIO(image_data) image_file.seek(0) return File(image_file, filename="avatar.png") + + +class Avatar(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.avatar.usage = lib.format.generate_usage(self.avatar) + + @commands.hybrid_command( + name="avatar", + aliases=["av"], + ) + async def avatar( + self, + ctx: commands.Context[commands.Bot], + member: discord.Member | None = None, + ) -> None: + """ + Get the avatar of a member. + + Parameters + ----------- + ctx : ApplicationContext + The discord context object. + member : Member + The member to get the avatar of. + """ + if member is None: + member = await commands.MemberConverter().convert(ctx, str(ctx.author.id)) + + guild_avatar: str | None = member.guild_avatar.url if member.guild_avatar else None + profile_avatar: str | None = member.avatar.url if member.avatar else None + + files: list[File] = [await create_avatar_file(avatar) for avatar in [guild_avatar, profile_avatar] if avatar] + + if files: + await ctx.send(files=files) + else: + await ctx.send(content="member has no avatar.") + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Avatar(bot)) diff --git a/modules/misc/backup.py b/modules/misc/backup.py index 4604348..8e4d3ff 100644 --- a/modules/misc/backup.py +++ b/modules/misc/backup.py @@ -1,19 +1,22 @@ +import asyncio import subprocess from datetime import datetime -from typing import List, Optional +from pathlib import Path +from zoneinfo import ZoneInfo -import dropbox -from dropbox.files import FileMetadata +import dropbox # type: ignore +from discord.ext import commands, tasks +from dropbox.files import FileMetadata # type: ignore from loguru import logger -from lib.constants import CONST +from lib.const import CONST # Initialize Dropbox client if instance is "main" -_dbx: Optional[dropbox.Dropbox] = None +_dbx: dropbox.Dropbox | None = None if CONST.INSTANCE and CONST.INSTANCE.lower() == "main": - _app_key: Optional[str] = CONST.DBX_APP_KEY - _dbx_token: Optional[str] = CONST.DBX_TOKEN - _app_secret: Optional[str] = CONST.DBX_APP_SECRET + _app_key: str | None = CONST.DBX_APP_KEY + _dbx_token: str | None = CONST.DBX_TOKEN + _app_secret: str | None = CONST.DBX_APP_SECRET _dbx = dropbox.Dropbox( app_key=_app_key, @@ -22,36 +25,42 @@ if CONST.INSTANCE and CONST.INSTANCE.lower() == "main": ) -async def create_db_backup() -> None: - if not _dbx: - raise ValueError("Dropbox client is not initialized") - - backup_name: str = datetime.today().strftime("%Y-%m-%d_%H%M") + "_lumi.sql" +def run_db_dump() -> None: command: str = ( f"mariadb-dump --user={CONST.MARIADB_USER} --password={CONST.MARIADB_PASSWORD} " f"--host=db --single-transaction --all-databases > ./db/migrations/100-dump.sql" ) - subprocess.check_output(command, shell=True) - with open("./db/migrations/100-dump.sql", "rb") as f: - _dbx.files_upload(f.read(), f"/{backup_name}") + +def upload_backup_to_dropbox(backup_name: str) -> None: + with Path("./db/migrations/100-dump.sql").open("rb") as f: + if _dbx: + _dbx.files_upload(f.read(), f"/{backup_name}") # type: ignore + + +async def create_db_backup() -> None: + if not _dbx: + msg = "Dropbox client is not initialized" + raise ValueError(msg) + + backup_name: str = datetime.now(ZoneInfo("US/Eastern")).strftime("%Y-%m-%d_%H%M") + "_lumi.sql" + + run_db_dump() + upload_backup_to_dropbox(backup_name) async def backup_cleanup() -> None: if not _dbx: - raise ValueError("Dropbox client is not initialized") + msg = "Dropbox client is not initialized" + raise ValueError(msg) - result = _dbx.files_list_folder("") + result = _dbx.files_list_folder("") # type: ignore - all_backup_files: List[str] = [ - entry.name - for entry in result.entries - if isinstance(entry, FileMetadata) # type: ignore - ] + all_backup_files: list[str] = [entry.name for entry in result.entries if isinstance(entry, FileMetadata)] # type: ignore for file in sorted(all_backup_files)[:-48]: - _dbx.files_delete_v2("/" + file) + _dbx.files_delete_v2(f"/{file}") # type: ignore async def backup() -> None: @@ -65,3 +74,22 @@ async def backup() -> None: logger.error(f"Backup failed: {error}") else: logger.debug('No backup, instance not "MAIN".') + + +class Backup(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.do_backup.start() + + @tasks.loop(hours=1) + async def do_backup(self) -> None: + await backup() + + @do_backup.before_loop + async def before_do_backup(self) -> None: + await self.bot.wait_until_ready() + await asyncio.sleep(30) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Backup(bot)) diff --git a/modules/misc/info.py b/modules/misc/info.py index 3d991af..6a5b8ef 100644 --- a/modules/misc/info.py +++ b/modules/misc/info.py @@ -3,40 +3,48 @@ import platform import discord import psutil -from discord.ext import bridge +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.currency_service import Currency -from services.stats_service import BlackJackStats +import lib.format +from lib.const import CONST +from ui.embeds import Builder -async def cmd(self, ctx: bridge.Context, unix_timestamp: int) -> None: - memory_usage_in_mb: float = psutil.Process().memory_info().rss / (1024 * 1024) - total_rows: str = Currency.format(BlackJackStats.get_total_rows_count()) +class Info(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.info.usage = lib.format.generate_usage(self.info) - description: str = "".join( - [ - CONST.STRINGS["info_uptime"].format(unix_timestamp), - CONST.STRINGS["info_latency"].format(round(1000 * self.client.latency)), - CONST.STRINGS["info_memory"].format(memory_usage_in_mb), - CONST.STRINGS["info_system"].format(platform.system(), os.name), - CONST.STRINGS["info_api_version"].format(discord.__version__), - CONST.STRINGS["info_database_records"].format(total_rows), - ], + @commands.hybrid_command( + name="info", ) + async def info(self, ctx: commands.Context[commands.Bot]) -> None: + memory_usage_in_mb: float = psutil.Process().memory_info().rss / (1024 * 1024) + # total_rows: str = Currency.format(BlackJackStats.get_total_rows_count()) - embed: discord.Embed = EmbedBuilder.create_success_embed( - ctx, - description=description, - footer_text=CONST.STRINGS["info_service_footer"], - show_name=False, - ) - embed.set_author( - name=f"{CONST.TITLE} v{CONST.VERSION}", - url=CONST.REPO_URL, - icon_url=CONST.CHECK_ICON, - ) - embed.set_thumbnail(url=CONST.LUMI_LOGO_OPAQUE) + description: str = "".join( + [ + CONST.STRINGS["info_latency"].format(round(1000 * self.bot.latency)), + CONST.STRINGS["info_memory"].format(memory_usage_in_mb), + CONST.STRINGS["info_system"].format(platform.system(), os.name), + CONST.STRINGS["info_api_version"].format(discord.__version__), + # CONST.STRINGS["info_database_records"].format(total_rows), + ], + ) - await ctx.respond(embed=embed) + embed: discord.Embed = Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=f"{CONST.TITLE} v{CONST.VERSION}", + author_url=CONST.REPO_URL, + description=description, + footer_text=CONST.STRINGS["info_service_footer"], + thumbnail_url=CONST.LUMI_LOGO_OPAQUE, + hide_name_in_description=True, + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Info(bot)) diff --git a/modules/misc/introduction.py b/modules/misc/introduction.py index f0ee366..152e876 100644 --- a/modules/misc/introduction.py +++ b/modules/misc/introduction.py @@ -1,166 +1,191 @@ -import asyncio -from typing import Dict, Optional - import discord -from discord.ext import bridge +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.interactions.introduction import ( +import lib.format +from lib.const import CONST +from ui.embeds import Builder +from ui.views.introduction import ( IntroductionFinishButtons, IntroductionStartButtons, ) -async def cmd(self, ctx: bridge.Context) -> None: - guild: Optional[discord.Guild] = self.client.get_guild(CONST.KRC_GUILD_ID) - member: Optional[discord.Member] = ( - guild.get_member(ctx.author.id) if guild else None - ) +class Introduction(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.introduction.usage = lib.format.generate_usage(self.introduction) - if not guild or not member: - await ctx.respond( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["intro_no_guild_author"], - description=CONST.STRINGS["intro_no_guild"], - footer_text=CONST.STRINGS["intro_service_name"], - ), + @commands.hybrid_command(name="introduction", aliases=["intro"]) + async def introduction(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Introduction command. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + guild: discord.Guild | None = self.bot.get_guild( + CONST.INTRODUCTIONS_GUILD_ID, ) - return + member: discord.Member | None = guild.get_member(ctx.author.id) if guild else None - question_mapping: Dict[str, str] = CONST.KRC_QUESTION_MAPPING - channel: Optional[discord.abc.GuildChannel] = guild.get_channel( - CONST.KRC_INTRO_CHANNEL_ID, - ) - - if not channel or isinstance( - channel, - (discord.ForumChannel, discord.CategoryChannel), - ): - await ctx.respond( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["intro_no_channel_author"], - description=CONST.STRINGS["intro_no_channel"], - footer_text=CONST.STRINGS["intro_service_name"], - ), - ) - return - - view: IntroductionStartButtons | IntroductionFinishButtons = ( - IntroductionStartButtons(ctx) - ) - await ctx.respond( - embed=EmbedBuilder.create_embed( - ctx, - author_text=CONST.STRINGS["intro_service_name"], - description=CONST.STRINGS["intro_start"].format(channel.mention), - footer_text=CONST.STRINGS["intro_start_footer"], - ), - view=view, - ) - await view.wait() - - if view.clickedStop: - await ctx.send( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["intro_stopped_author"], - description=CONST.STRINGS["intro_stopped"], - footer_text=CONST.STRINGS["intro_service_name"], - ), - ) - return - - if view.clickedStart: - - def check(message: discord.Message) -> bool: - return message.author == ctx.author and isinstance( - message.channel, - discord.DMChannel, - ) - - answer_mapping: Dict[str, str] = {} - - for key, question in question_mapping.items(): + if not guild or not member: await ctx.send( - embed=EmbedBuilder.create_embed( - ctx, - author_text=key, - description=question, - footer_text=CONST.STRINGS["intro_question_footer"], + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_no_guild_author"], + description=CONST.STRINGS["intro_no_guild"], + footer_text=CONST.STRINGS["intro_service_name"], ), ) + return - try: - answer: discord.Message = await self.client.wait_for( - "message", - check=check, - timeout=300, - ) - answer_content: str = answer.content.replace("\n", " ") - - if len(answer_content) > 200: - await ctx.send( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["intro_too_long_author"], - description=CONST.STRINGS["intro_too_long"], - footer_text=CONST.STRINGS["intro_service_name"], - ), - ) - return - - answer_mapping[key] = answer_content - - except asyncio.TimeoutError: - await ctx.send( - embed=EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["intro_timeout_author"], - description=CONST.STRINGS["intro_timeout"], - footer_text=CONST.STRINGS["intro_service_name"], - ), - ) - return - - description: str = "".join( - CONST.STRINGS["intro_preview_field"].format(key, value) - for key, value in answer_mapping.items() + question_mapping: dict[str, str] = CONST.INTRODUCTIONS_QUESTION_MAPPING + channel: discord.abc.GuildChannel | None = guild.get_channel( + CONST.INTRODUCTIONS_CHANNEL_ID, ) - preview: discord.Embed = EmbedBuilder.create_embed( - ctx, - author_text=ctx.author.name, - author_icon_url=ctx.author.display_avatar.url, - description=description, - footer_text=CONST.STRINGS["intro_content_footer"], - ) - view = IntroductionFinishButtons(ctx) + if not channel or isinstance( + channel, + discord.ForumChannel | discord.CategoryChannel, + ): + await ctx.send( + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_no_channel_author"], + description=CONST.STRINGS["intro_no_channel"], + footer_text=CONST.STRINGS["intro_service_name"], + ), + ) + return + + view: IntroductionStartButtons | IntroductionFinishButtons = IntroductionStartButtons(ctx) + await ctx.send( + embed=Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_service_name"], + description=CONST.STRINGS["intro_start"].format(channel.mention), + footer_text=CONST.STRINGS["intro_start_footer"], + ), + view=view, + ) - await ctx.send(embed=preview, view=view) await view.wait() - if view.clickedConfirm: - await channel.send( - embed=preview, - content=CONST.STRINGS["intro_content"].format(ctx.author.mention), - ) + if view.clicked_stop: await ctx.send( - embed=EmbedBuilder.create_embed( - ctx, - description=CONST.STRINGS["intro_post_confirmation"].format( - channel.mention, - ), - ), - ) - else: - await ctx.send( - embed=EmbedBuilder.create_error_embed( - ctx, + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, author_text=CONST.STRINGS["intro_stopped_author"], description=CONST.STRINGS["intro_stopped"], footer_text=CONST.STRINGS["intro_service_name"], ), ) + return + + if view.clicked_start: + + def check(message: discord.Message) -> bool: + return message.author == ctx.author and isinstance( + message.channel, + discord.DMChannel, + ) + + answer_mapping: dict[str, str] = {} + + for key, question in question_mapping.items(): + await ctx.send( + embed=Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=key, + description=question, + footer_text=CONST.STRINGS["intro_question_footer"], + ), + ) + + try: + answer: discord.Message = await self.bot.wait_for( + "message", + check=check, + timeout=300, + ) + answer_content: str = answer.content.replace("\n", " ") + + if len(answer_content) > 200: + await ctx.send( + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_too_long_author"], + description=CONST.STRINGS["intro_too_long"], + footer_text=CONST.STRINGS["intro_service_name"], + ), + ) + return + + answer_mapping[key] = answer_content + + except TimeoutError: + await ctx.send( + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_timeout_author"], + description=CONST.STRINGS["intro_timeout"], + footer_text=CONST.STRINGS["intro_service_name"], + ), + ) + return + + description: str = "".join( + CONST.STRINGS["intro_preview_field"].format(key, value) for key, value in answer_mapping.items() + ) + + preview: discord.Embed = Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=ctx.author.name, + author_icon_url=ctx.author.display_avatar.url, + description=description, + footer_text=CONST.STRINGS["intro_content_footer"], + ) + view = IntroductionFinishButtons(ctx) + + await ctx.send(embed=preview, view=view) + await view.wait() + + if view.clicked_confirm: + await channel.send( + embed=preview, + content=CONST.STRINGS["intro_content"].format(ctx.author.mention), + ) + await ctx.send( + embed=Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_post_confirmation_author"], + description=CONST.STRINGS["intro_post_confirmation"].format( + channel.mention, + ), + ), + ) + else: + await ctx.send( + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["intro_stopped_author"], + description=CONST.STRINGS["intro_stopped"], + footer_text=CONST.STRINGS["intro_service_name"], + ), + ) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Introduction(bot)) diff --git a/modules/misc/invite.py b/modules/misc/invite.py index 79df5e0..b237737 100644 --- a/modules/misc/invite.py +++ b/modules/misc/invite.py @@ -1,27 +1,36 @@ -from discord import ButtonStyle -from discord.ext import bridge -from discord.ui import Button, View +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +import lib.format +from lib.const import CONST +from ui.embeds import Builder +from ui.views.invite import InviteButton -async def cmd(ctx: bridge.BridgeContext) -> None: - await ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - description=CONST.STRINGS["invite_description"], - ), - view=InviteButton(), - ) +class Invite(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.invite.usage = lib.format.generate_usage(self.invite) + @commands.hybrid_command(name="invite", aliases=["inv"]) + async def invite(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Invite command. -class InviteButton(View): - def __init__(self) -> None: - super().__init__(timeout=None) - invite_button: Button = Button( - label=CONST.STRINGS["invite_button_text"], - style=ButtonStyle.url, - url=CONST.INVITE_LINK, + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + await ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["invite_author"], + description=CONST.STRINGS["invite_description"], + ), + view=InviteButton(), ) - self.add_item(invite_button) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Invite(bot)) diff --git a/modules/misc/ping.py b/modules/misc/ping.py index d45c5c8..746830b 100644 --- a/modules/misc/ping.py +++ b/modules/misc/ping.py @@ -1,32 +1,37 @@ -from datetime import datetime +from discord.ext import commands -from discord.ext import bridge - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder +import lib.format +from lib.const import CONST +from ui.embeds import Builder -async def ping(self, ctx: bridge.BridgeContext) -> None: - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["ping_author"], - description=CONST.STRINGS["ping_pong"], - footer_text=CONST.STRINGS["ping_footer"].format( - round(1000 * self.client.latency), - ), - ) - await ctx.respond(embed=embed) +class Ping(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.ping.usage = lib.format.generate_usage(self.ping) + + @commands.hybrid_command(name="ping") + async def ping(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Ping command. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["ping_author"], + description=CONST.STRINGS["ping_pong"], + footer_text=CONST.STRINGS["ping_footer"].format( + round(1000 * self.bot.latency), + ), + ) + + await ctx.send(embed=embed) -async def uptime(self, ctx: bridge.BridgeContext, start_time: datetime) -> None: - unix_timestamp: int = int(round(self.start_time.timestamp())) - - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["ping_author"], - description=CONST.STRINGS["ping_uptime"].format(unix_timestamp), - footer_text=CONST.STRINGS["ping_footer"].format( - round(1000 * self.client.latency), - ), - ) - await ctx.respond(embed=embed) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Ping(bot)) diff --git a/modules/misc/uptime.py b/modules/misc/uptime.py new file mode 100644 index 0000000..3169ef5 --- /dev/null +++ b/modules/misc/uptime.py @@ -0,0 +1,43 @@ +from datetime import datetime + +import discord +from discord import Embed +from discord.ext import commands + +import lib.format +from lib.const import CONST +from ui.embeds import Builder + + +class Uptime(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + self.start_time: datetime = discord.utils.utcnow() + self.uptime.usage = lib.format.generate_usage(self.uptime) + + @commands.hybrid_command(name="uptime") + async def uptime(self, ctx: commands.Context[commands.Bot]) -> None: + """ + Uptime command. + + Parameters + ---------- + ctx : commands.Context[commands.Bot] + The context of the command. + """ + unix_timestamp: int = int(self.start_time.timestamp()) + + embed: Embed = Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=CONST.STRINGS["ping_author"], + description=CONST.STRINGS["ping_uptime"].format(unix_timestamp), + footer_text=CONST.STRINGS["ping_footer"].format( + int(self.bot.latency * 1000), + ), + ) + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Uptime(bot)) diff --git a/modules/misc/xkcd.py b/modules/misc/xkcd.py index 0b88120..cd335fd 100644 --- a/modules/misc/xkcd.py +++ b/modules/misc/xkcd.py @@ -1,18 +1,18 @@ -from typing import Optional +import discord +from discord import app_commands +from discord.ext import commands -from discord.ext import bridge - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.xkcd_service import Client, HttpError +from lib.const import CONST +from ui.embeds import Builder +from wrappers.xkcd import Client, HttpError _xkcd = Client() async def print_comic( - ctx: bridge.Context, + interaction: discord.Interaction, latest: bool = False, - number: Optional[int] = None, + number: int | None = None, ) -> None: try: if latest: @@ -22,9 +22,9 @@ async def print_comic( else: comic = _xkcd.get_random_comic(raw_comic_image=True) - await ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, + await interaction.response.send_message( + embed=Builder.create_embed( + theme="info", author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title), description=CONST.STRINGS["xkcd_description"].format( comic.explanation_url, @@ -32,16 +32,64 @@ async def print_comic( ), footer_text=CONST.STRINGS["xkcd_footer"], image_url=comic.image_url, - show_name=False, ), ) except HttpError: - await ctx.respond( - embed=EmbedBuilder.create_error_embed( - ctx, + await interaction.response.send_message( + embed=Builder.create_embed( + theme="error", author_text=CONST.STRINGS["xkcd_not_found_author"], description=CONST.STRINGS["xkcd_not_found"], footer_text=CONST.STRINGS["xkcd_footer"], ), ) + + +class Xkcd(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + xkcd = app_commands.Group(name="xkcd", description="Get the latest xkcd comic") + + @xkcd.command(name="latest") + async def xkcd_latest(self, interaction: discord.Interaction) -> None: + """ + Get the latest xkcd comic. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to get the latest comic for. + """ + await print_comic(interaction, latest=True) + + @xkcd.command(name="random") + async def xkcd_random(self, interaction: discord.Interaction) -> None: + """ + Get a random xkcd comic. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to get the random comic for. + """ + await print_comic(interaction) + + @xkcd.command(name="search") + async def xkcd_search(self, interaction: discord.Interaction, comic_id: int) -> None: + """ + Get a specific xkcd comic. + + Parameters + ---------- + interaction : discord.Interaction + The interaction to get the comic for. + comic_id : int + The ID of the comic to get. + """ + await print_comic(interaction, number=comic_id) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Xkcd(bot)) diff --git a/modules/moderation/__init__.py b/modules/moderation/__init__.py index 730d70a..e69de29 100644 --- a/modules/moderation/__init__.py +++ b/modules/moderation/__init__.py @@ -1,195 +0,0 @@ -import discord -from discord.ext import bridge, commands -from discord.ext.commands import guild_only - -from modules.moderation import ban, cases, kick, softban, timeout, warn - - -class Moderation(commands.Cog): - def __init__(self, client): - self.client = client - - @bridge.bridge_command( - name="ban", - aliases=["b"], - description="Ban a user from the server.", - help="Bans a user from the server, you can use ID or mention them.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(ban_members=True) - @commands.bot_has_permissions(ban_members=True) - @guild_only() - async def ban_command( - self, - ctx, - target: discord.User, - *, - reason: str | None = None, - ): - await ban.ban_user(self, ctx, target, reason) - - @bridge.bridge_command( - name="case", - aliases=["c"], - description="View a case by its number.", - help="Views a case by its number in the server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(view_audit_log=True) - @guild_only() - async def case_command(self, ctx, case_number: int): - await cases.view_case_by_number(ctx, ctx.guild.id, case_number) - - @bridge.bridge_command( - name="cases", - aliases=["caselist"], - description="View all cases in the server.", - help="Lists all moderation cases for the current server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(view_audit_log=True) - @guild_only() - async def cases_command(self, ctx): - await cases.view_all_cases_in_guild(ctx, ctx.guild.id) - - @bridge.bridge_command( - name="editcase", - aliases=["uc", "ec"], - description="Edit the reason for a case.", - help="Updates the reason for a specific case in the server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(view_audit_log=True) - @guild_only() - async def edit_case_command(self, ctx, case_number: int, *, new_reason: str): - await cases.edit_case_reason(ctx, ctx.guild.id, case_number, new_reason) - - @bridge.bridge_command( - name="kick", - aliases=["k"], - description="Kick a user from the server.", - help="Kicks a user from the server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(kick_members=True) - @commands.bot_has_permissions(kick_members=True) - @guild_only() - async def kick_command( - self, - ctx, - target: discord.Member, - *, - reason: str | None = None, - ): - await kick.kick_user(self, ctx, target, reason) - - @bridge.bridge_command( - name="modcases", - aliases=["moderatorcases", "mc"], - description="View all cases by a specific moderator.", - help="Lists all moderation cases handled by a specific moderator in the current server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(view_audit_log=True) - @guild_only() - async def moderator_cases_command(self, ctx, moderator: discord.Member): - await cases.view_all_cases_by_mod(ctx, ctx.guild.id, moderator) - - @bridge.bridge_command( - name="softban", - aliases=["sb"], - description="Softban a user from the server.", - help="Softbans a user from the server (ban and immediately unban to delete messages).", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(ban_members=True) - @commands.bot_has_permissions(ban_members=True) - @guild_only() - async def softban_command( - self, - ctx, - target: discord.Member, - *, - reason: str | None = None, - ): - await softban.softban_user(ctx, target, reason) - - @bridge.bridge_command( - name="timeout", - aliases=["t", "to"], - description="Timeout a user.", - help="Timeouts a user in the server for a specified duration.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(moderate_members=True) - @commands.bot_has_permissions(moderate_members=True) - @guild_only() - async def timeout_command( - self, - ctx, - target: discord.Member, - duration: str, - *, - reason: str | None = None, - ): - await timeout.timeout_user(self, ctx, target, duration, reason) - - @bridge.bridge_command( - name="unban", - aliases=["ub", "pardon"], - description="Unbans a user from the server.", - help="Unbans a user from the server, you can use ID or provide their username.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(ban_members=True) - @commands.bot_has_permissions(ban_members=True) - @guild_only() - async def unban_command( - self, - ctx, - target: discord.User, - *, - reason: str | None = None, - ): - await ban.unban_user(ctx, target, reason) - - @bridge.bridge_command( - name="untimeout", - aliases=["removetimeout", "rto", "uto"], - description="Remove timeout from a user.", - help="Removes the timeout from a user in the server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(moderate_members=True) - @commands.bot_has_permissions(moderate_members=True) - @guild_only() - async def untimeout_command( - self, - ctx, - target: discord.Member, - *, - reason: str | None = None, - ): - await timeout.untimeout_user(ctx, target, reason) - - @bridge.bridge_command( - name="warn", - aliases=["w"], - description="Warn a user.", - help="Warns a user in the server.", - contexts={discord.InteractionContextType.guild}, - ) - @bridge.has_permissions(kick_members=True) - @guild_only() - async def warn_command( - self, - ctx, - target: discord.Member, - *, - reason: str | None = None, - ): - await warn.warn_user(ctx, target, reason) - - -def setup(client): - client.add_cog(Moderation(client)) diff --git a/modules/moderation/ban.py b/modules/moderation/ban.py index 4eedbd0..271b7ff 100644 --- a/modules/moderation/ban.py +++ b/modules/moderation/ban.py @@ -1,114 +1,149 @@ import asyncio -from typing import Optional +import contextlib +from typing import cast import discord -from discord.ext.commands import MemberConverter +from discord.ext import commands -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from modules.moderation.utils.actionable import async_actionable -from modules.moderation.utils.case_handler import create_case +import lib.format +from lib.actionable import async_actionable +from lib.case_handler import create_case +from lib.const import CONST +from ui.embeds import Builder -async def ban_user(cog, ctx, target: discord.User, reason: Optional[str] = None): - # see if user is in guild - member = await MemberConverter().convert(ctx, str(target.id)) +class Ban(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.ban.usage = lib.format.generate_usage(self.ban) + self.unban.usage = lib.format.generate_usage(self.unban) - output_reason = reason or CONST.STRINGS["mod_no_reason"] + @commands.hybrid_command(name="ban", aliases=["b"]) + @commands.has_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + @commands.guild_only() + async def ban( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member | discord.User, + *, + reason: str | None = None, + ) -> None: + """ + Ban a user from the guild. - # member -> user is in the guild, check role hierarchy - if member: - bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) - await async_actionable(member, ctx.author, bot_member) + Parameters + ---------- + target: discord.Member | discord.User + The user to ban. + reason: str | None + The reason for the ban. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user + + output_reason = reason or CONST.STRINGS["mod_no_reason"] + formatted_reason = CONST.STRINGS["mod_reason"].format( + ctx.author.name, + lib.format.shorten(output_reason, 200), + ) + + dm_sent = False + if isinstance(target, discord.Member): + bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id)) + await async_actionable(target, cast(discord.Member, ctx.author), bot_member) + + with contextlib.suppress(discord.HTTPException, discord.Forbidden): + await target.send( + embed=Builder.create_embed( + theme="warning", + user_name=target.name, + author_text=CONST.STRINGS["mod_banned_author"], + description=CONST.STRINGS["mod_ban_dm"].format( + target.name, + ctx.guild.name, + output_reason, + ), + hide_name_in_description=True, + ), + ) + dm_sent = True + + await ctx.guild.ban(target, reason=formatted_reason) + + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_banned_author"], + description=CONST.STRINGS["mod_banned_user"].format(target.name), + ) + if isinstance(target, discord.Member): + embed.set_footer(text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"]) + + await asyncio.gather( + ctx.send(embed=embed), + create_case(ctx, cast(discord.User, target), "BAN", reason), + return_exceptions=True, + ) + + @commands.hybrid_command(name="unban") + @commands.has_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + @commands.guild_only() + async def unban( + self, + ctx: commands.Context[commands.Bot], + target: discord.User, + *, + reason: str | None = None, + ) -> None: + """ + Unban a user from the guild. + + Parameters + ---------- + target: discord.User + The user to unban. + reason: str | None + The reason for the unban. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user + + output_reason = reason or CONST.STRINGS["mod_no_reason"] try: - await member.send( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_banned_author"], - description=CONST.STRINGS["mod_ban_dm"].format( - target.name, - ctx.guild.name, - output_reason, - ), - show_name=False, + await ctx.guild.unban( + target, + reason=CONST.STRINGS["mod_reason"].format( + ctx.author.name, + lib.format.shorten(output_reason, 200), ), ) - dm_sent = True - except (discord.HTTPException, discord.Forbidden): - dm_sent = False + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_unbanned_author"], + description=CONST.STRINGS["mod_unbanned"].format(target.name), + ), + ) + create_case_task = create_case(ctx, target, "UNBAN", reason) + await asyncio.gather(respond_task, create_case_task) - await member.ban( - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - delete_message_seconds=86400, - ) - - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_banned_author"], - description=CONST.STRINGS["mod_banned_user"].format(target.id), - footer_text=CONST.STRINGS["mod_dm_sent"] - if dm_sent - else CONST.STRINGS["mod_dm_not_sent"], - ), - ) - create_case_task = create_case(ctx, target, "BAN", reason) - await asyncio.gather(respond_task, create_case_task, return_exceptions=True) - - # not a member in this guild, so ban right away - else: - await ctx.guild.ban( - target, - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - ) - - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_banned_author"], - description=CONST.STRINGS["mod_banned_user"].format(target.id), - ), - ) - create_case_task = create_case(ctx, target, "BAN", reason) - await asyncio.gather(respond_task, create_case_task) + except (discord.NotFound, discord.HTTPException): + await ctx.send( + embed=Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_not_banned_author"], + description=CONST.STRINGS["mod_not_banned"].format(target.id), + ), + ) -async def unban_user(ctx, target: discord.User, reason: Optional[str] = None): - output_reason = reason or CONST.STRINGS["mod_no_reason"] - - try: - await ctx.guild.unban( - target, - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - ) - - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_unbanned_author"], - description=CONST.STRINGS["mod_unbanned"].format(target.id), - ), - ) - create_case_task = create_case(ctx, target, "UNBAN", reason) - await asyncio.gather(respond_task, create_case_task) - - except (discord.NotFound, discord.HTTPException): - return await ctx.respond( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_not_banned_author"], - description=CONST.STRINGS["mod_not_banned"].format(target.id), - ), - ) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Ban(bot)) diff --git a/modules/moderation/cases.py b/modules/moderation/cases.py index 5d56c58..187b93f 100644 --- a/modules/moderation/cases.py +++ b/modules/moderation/cases.py @@ -1,115 +1,236 @@ import asyncio import discord -from discord.ext import pages -from discord.ext.commands import UserConverter +from discord.ext import commands +from reactionmenu import ViewButton, ViewMenu -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.formatter import format_case_number -from modules.moderation.utils.case_embed import ( +import lib.format +from lib.case_handler import edit_case_modlog +from lib.const import CONST +from lib.exceptions import LumiException +from lib.format import format_case_number +from services.case_service import CaseService +from ui.cases import ( create_case_embed, create_case_list_embed, ) -from modules.moderation.utils.case_handler import edit_case_modlog -from services.moderation.case_service import CaseService +from ui.embeds import Builder case_service = CaseService() -async def view_case_by_number(ctx, guild_id: int, case_number: int): - case = case_service.fetch_case_by_guild_and_number(guild_id, case_number) - - if not case: - embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["error_no_case_found_author"], - description=CONST.STRINGS["error_no_case_found_description"], - ) - return await ctx.respond(embed=embed) - - target = await UserConverter().convert(ctx, str(case["target_id"])) - embed: discord.Embed = create_case_embed( - ctx=ctx, - target=target, - case_number=case["case_number"], - action_type=case["action_type"], - reason=case["reason"], - timestamp=case["created_at"], - duration=case["duration"] or None, - ) - await ctx.respond(embed=embed) - - -async def view_all_cases_in_guild(ctx, guild_id: int): - cases = case_service.fetch_cases_by_guild(guild_id) - - if not cases: - embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["case_guild_no_cases_author"], - description=CONST.STRINGS["case_guild_no_cases"], - ) - return await ctx.respond(embed=embed) - - pages_list = [] - for i in range(0, len(cases), 10): - chunk = cases[i : i + 10] - embed = create_case_list_embed( - ctx, - chunk, - CONST.STRINGS["case_guild_cases_author"], - ) - pages_list.append(embed) - - paginator = pages.Paginator(pages=pages_list) - await paginator.respond(ctx) - - -async def view_all_cases_by_mod(ctx, guild_id: int, moderator: discord.Member): - cases = case_service.fetch_cases_by_moderator(guild_id, moderator.id) - - if not cases: - embed = EmbedBuilder.create_error_embed( - ctx, - author_text=CONST.STRINGS["case_mod_no_cases_author"], - description=CONST.STRINGS["case_mod_no_cases"], - ) - return await ctx.respond(embed=embed) - - pages_list = [] - for i in range(0, len(cases), 10): - chunk = cases[i : i + 10] - embed = create_case_list_embed( - ctx, - chunk, - CONST.STRINGS["case_mod_cases_author"].format(moderator.name), - ) - pages_list.append(embed) - - paginator = pages.Paginator(pages=pages_list) - await paginator.respond(ctx) - - -async def edit_case_reason(ctx, guild_id: int, case_number: int, new_reason: str): - case_service.edit_case_reason( - guild_id, - case_number, - new_reason, +def create_no_cases_embed(ctx: commands.Context[commands.Bot], author_text: str, description: str) -> discord.Embed: + return Builder.create_embed( + theme="info", + user_name=ctx.author.name, + author_text=author_text, + description=description, ) - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["case_reason_update_author"], - description=CONST.STRINGS["case_reason_update_description"].format( - format_case_number(case_number), - ), - ) - async def update_tasks(): - await asyncio.gather( - ctx.respond(embed=embed), - edit_case_modlog(ctx, guild_id, case_number, new_reason), +def create_case_view_menu(ctx: commands.Context[commands.Bot]) -> ViewMenu: + menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed, all_can_click=True, delete_on_timeout=True) + + buttons = [ + (ViewButton.ID_GO_TO_FIRST_PAGE, "⏮️"), + (ViewButton.ID_PREVIOUS_PAGE, "⏪"), + (ViewButton.ID_NEXT_PAGE, "⏩"), + (ViewButton.ID_GO_TO_LAST_PAGE, "⏭️"), + ] + + for custom_id, emoji in buttons: + menu.add_button(ViewButton(style=discord.ButtonStyle.secondary, custom_id=custom_id, emoji=emoji)) + + return menu + + +class Cases(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.view_case_by_number.usage = lib.format.generate_usage(self.view_case_by_number) + self.view_all_cases_in_guild.usage = lib.format.generate_usage(self.view_all_cases_in_guild) + self.view_all_cases_by_mod.usage = lib.format.generate_usage(self.view_all_cases_by_mod) + self.edit_case_reason.usage = lib.format.generate_usage(self.edit_case_reason) + + @commands.hybrid_command(name="case", aliases=["c", "ca"]) + @commands.has_permissions(manage_messages=True) + @commands.guild_only() + async def view_case_by_number( + self, + ctx: commands.Context[commands.Bot], + case_number: int | None = None, + ) -> None: + """ + View a specific case by number or all cases if no number is provided. + + Parameters + ---------- + case_number: int | None + The case number to view. If None, view all cases. + """ + if case_number is None: + await ctx.invoke(self.view_all_cases_in_guild) + return + + guild_id = ctx.guild.id if ctx.guild else 0 + case = case_service.fetch_case_by_guild_and_number(guild_id, case_number) + + if not case: + embed = create_no_cases_embed( + ctx, + CONST.STRINGS["error_no_case_found_author"], + CONST.STRINGS["error_no_case_found_description"], + ) + await ctx.send(embed=embed) + return + + target = await commands.UserConverter().convert(ctx, str(case["target_id"])) + embed: discord.Embed = create_case_embed( + ctx=ctx, + target=target, + case_number=case["case_number"], + action_type=case["action_type"], + reason=case["reason"], + timestamp=case["created_at"], + duration=case["duration"] or None, + ) + await ctx.send(embed=embed) + + @commands.hybrid_command(name="cases") + @commands.has_permissions(manage_messages=True) + @commands.guild_only() + async def view_all_cases_in_guild( + self, + ctx: commands.Context[commands.Bot], + ) -> None: + """ + View all cases in the guild. + + Parameters + ---------- + ctx: commands.Context[commands.Bot] + The context of the command. + """ + if not ctx.guild: + raise LumiException(CONST.STRINGS["error_not_in_guild"]) + + guild_id = ctx.guild.id + cases = case_service.fetch_cases_by_guild(guild_id) + + if not cases: + embed = create_no_cases_embed( + ctx, + CONST.STRINGS["case_guild_no_cases_author"], + CONST.STRINGS["case_guild_no_cases"], + ) + await ctx.send(embed=embed) + return + + menu = create_case_view_menu(ctx) + + for i in range(0, len(cases), 10): + chunk = cases[i : i + 10] + embed = create_case_list_embed( + ctx, + chunk, + CONST.STRINGS["case_guild_cases_author"], + ) + menu.add_page(embed) + + await menu.start() + + @commands.hybrid_command(name="modcases", aliases=["mc", "modc"]) + @commands.has_permissions(manage_messages=True) + @commands.guild_only() + async def view_all_cases_by_mod( + self, + ctx: commands.Context[commands.Bot], + moderator: discord.Member, + ) -> None: + """ + View all cases by a specific moderator. + + Parameters + ---------- + moderator: discord.Member + The moderator to view cases for. + """ + if not ctx.guild: + raise LumiException(CONST.STRINGS["error_not_in_guild"]) + + guild_id = ctx.guild.id + cases = case_service.fetch_cases_by_moderator(guild_id, moderator.id) + + if not cases: + embed = create_no_cases_embed( + ctx, + CONST.STRINGS["case_mod_no_cases_author"], + CONST.STRINGS["case_mod_no_cases"], + ) + await ctx.send(embed=embed) + return + + menu = create_case_view_menu(ctx) + + for i in range(0, len(cases), 10): + chunk = cases[i : i + 10] + embed = create_case_list_embed( + ctx, + chunk, + CONST.STRINGS["case_mod_cases_author"].format(moderator.name), + ) + menu.add_page(embed) + + await menu.start() + + @commands.hybrid_command(name="editcase", aliases=["ec", "uc"]) + @commands.has_permissions(manage_messages=True) + @commands.guild_only() + async def edit_case_reason( + self, + ctx: commands.Context[commands.Bot], + case_number: int, + *, + new_reason: str, + ) -> None: + """ + Edit the reason for a specific case. + + Parameters + ---------- + case_number: int + The case number to edit. + new_reason: str + The new reason for the case. + """ + if not ctx.guild: + raise LumiException(CONST.STRINGS["error_not_in_guild"]) + + guild_id = ctx.guild.id + + case_service.edit_case_reason( + guild_id, + case_number, + new_reason, ) - await update_tasks() + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["case_reason_update_author"], + description=CONST.STRINGS["case_reason_update_description"].format( + format_case_number(case_number), + ), + ) + + async def update_tasks(): + await asyncio.gather( + ctx.send(embed=embed), + edit_case_modlog(ctx, guild_id, case_number, new_reason), + ) + + await update_tasks() + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Cases(bot)) diff --git a/modules/moderation/kick.py b/modules/moderation/kick.py index 87e5d76..42a1b37 100644 --- a/modules/moderation/kick.py +++ b/modules/moderation/kick.py @@ -1,58 +1,90 @@ import asyncio -from typing import Optional +from typing import cast import discord -from discord.ext.commands import UserConverter, MemberConverter +from discord.ext import commands -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from modules.moderation.utils.actionable import async_actionable -from modules.moderation.utils.case_handler import create_case +import lib.format +from lib.actionable import async_actionable +from lib.case_handler import create_case +from lib.const import CONST +from ui.embeds import Builder -async def kick_user(cog, ctx, target: discord.Member, reason: Optional[str] = None): - bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) - await async_actionable(target, ctx.author, bot_member) +class Kick(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.kick.usage = lib.format.generate_usage(self.kick) - output_reason = reason or CONST.STRINGS["mod_no_reason"] + @commands.hybrid_command(name="kick", aliases=["k"]) + @commands.has_permissions(kick_members=True) + @commands.bot_has_permissions(kick_members=True) + @commands.guild_only() + async def kick( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member, + *, + reason: str | None = None, + ) -> None: + """ + Kick a user from the guild. - try: - await target.send( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_kicked_author"], - description=CONST.STRINGS["mod_kick_dm"].format( - target.name, - ctx.guild.name, - output_reason, + Parameters + ---------- + target: discord.Member + The user to kick. + reason: str | None + The reason for the kick. Defaults to None. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user + + bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id)) + await async_actionable(target, cast(discord.Member, ctx.author), bot_member) + + output_reason = reason or CONST.STRINGS["mod_no_reason"] + + try: + await target.send( + embed=Builder.create_embed( + theme="warning", + user_name=target.name, + author_text=CONST.STRINGS["mod_kicked_author"], + description=CONST.STRINGS["mod_kick_dm"].format( + target.name, + ctx.guild.name, + output_reason, + ), + hide_name_in_description=True, ), - show_name=False, + ) + dm_sent = True + + except (discord.HTTPException, discord.Forbidden): + dm_sent = False + + await target.kick( + reason=CONST.STRINGS["mod_reason"].format( + ctx.author.name, + lib.format.shorten(output_reason, 200), ), ) - dm_sent = True - except (discord.HTTPException, discord.Forbidden): - dm_sent = False + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_kicked_author"], + description=CONST.STRINGS["mod_kicked_user"].format(target.name), + footer_text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"], + ), + ) - await target.kick( - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - ) + create_case_task = create_case(ctx, cast(discord.User, target), "KICK", reason) + await asyncio.gather(respond_task, create_case_task, return_exceptions=True) - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_kicked_author"], - description=CONST.STRINGS["mod_kicked_user"].format(target.name), - footer_text=CONST.STRINGS["mod_dm_sent"] - if dm_sent - else CONST.STRINGS["mod_dm_not_sent"], - ), - ) - target_user = await UserConverter().convert(ctx, str(target.id)) - create_case_task = create_case(ctx, target_user, "KICK", reason) - await asyncio.gather(respond_task, create_case_task, return_exceptions=True) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Kick(bot)) diff --git a/modules/moderation/slowmode.py b/modules/moderation/slowmode.py new file mode 100644 index 0000000..a2651c0 --- /dev/null +++ b/modules/moderation/slowmode.py @@ -0,0 +1,123 @@ +import contextlib + +import discord +from discord import app_commands +from discord.ext import commands + +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException +from lib.format import format_duration_to_seconds + + +class Slowmode(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.slowmode.usage = lib.format.generate_usage(self.slowmode) + + async def _set_slowmode( + self, + ctx: commands.Context[commands.Bot] | discord.Interaction, + channel: discord.TextChannel, + duration: str | None, + ) -> None: + if duration is None: + await self._send_response( + ctx, + CONST.STRINGS["slowmode_current_value"].format(channel.mention, channel.slowmode_delay), + ) + return + + try: + seconds = format_duration_to_seconds(duration) + except LumiException: + await self._send_response(ctx, CONST.STRINGS["slowmode_invalid_duration"], ephemeral=True) + return + + if not 0 <= seconds <= 21600: # 21600 seconds = 6 hours (Discord's max slowmode) + await self._send_response(ctx, CONST.STRINGS["slowmode_invalid_duration"], ephemeral=True) + return + + try: + await channel.edit(slowmode_delay=seconds) + await self._send_response(ctx, CONST.STRINGS["slowmode_success"].format(seconds, channel.mention)) + except discord.Forbidden: + await self._send_response(ctx, CONST.STRINGS["slowmode_forbidden"], ephemeral=True) + + async def _send_response( + self, + ctx: commands.Context[commands.Bot] | discord.Interaction, + content: str, + ephemeral: bool = False, + ) -> None: + if isinstance(ctx, commands.Context): + await ctx.send(content) + else: + await ctx.response.send_message(content, ephemeral=ephemeral) + + @commands.command( + name="slowmode", + aliases=["sm"], + ) + @commands.has_permissions(manage_channels=True) + @commands.bot_has_permissions(manage_channels=True) + @commands.guild_only() + async def slowmode( + self, + ctx: commands.Context[commands.Bot], + arg1: str | None = None, + arg2: str | None = None, + ) -> None: + """ + Set or view the slowmode for a channel. + + Parameters + ---------- + arg1: str | None + The first argument. Defaults to None. + arg2: str | None + The second argument. Defaults to None. + """ + channel, duration = None, None + + for arg in (arg1, arg2): + if not channel and arg: + with contextlib.suppress(commands.BadArgument): + channel = await commands.TextChannelConverter().convert(ctx, arg) + continue + if arg: + duration = arg + + if not channel: + await ctx.send(CONST.STRINGS["slowmode_channel_not_found"]) + return + + await self._set_slowmode(ctx, channel, duration) + + @app_commands.command( + name="slowmode", + ) + @app_commands.checks.has_permissions(manage_channels=True) + @app_commands.checks.bot_has_permissions(manage_channels=True) + @app_commands.guild_only() + async def slowmode_slash( + self, + interaction: discord.Interaction, + channel: discord.TextChannel, + duration: str | None = None, + ) -> None: + """ + Set or view the slowmode for a channel. + + Parameters + ---------- + channel: discord.TextChannel + The channel to set the slowmode for. + duration: str | None + The duration of the slowmode. Defaults to None. + """ + await self._set_slowmode(interaction, channel, duration) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Slowmode(bot)) diff --git a/modules/moderation/softban.py b/modules/moderation/softban.py index a6c3b07..e712f73 100644 --- a/modules/moderation/softban.py +++ b/modules/moderation/softban.py @@ -1,66 +1,98 @@ import asyncio -from typing import Optional +from typing import cast import discord -from discord.ext.commands import MemberConverter, UserConverter +from discord.ext import commands -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from modules.moderation.utils.actionable import async_actionable -from modules.moderation.utils.case_handler import create_case +import lib.format +from lib.actionable import async_actionable +from lib.case_handler import create_case +from lib.const import CONST +from ui.embeds import Builder -async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None): - bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) - await async_actionable(target, ctx.author, bot_member) +class Softban(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.softban.usage = lib.format.generate_usage(self.softban) - output_reason = reason or CONST.STRINGS["mod_no_reason"] + @commands.hybrid_command(name="softban", aliases=["sb"]) + @commands.has_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + @commands.guild_only() + async def softban( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member, + *, + reason: str | None = None, + ) -> None: + """ + Softban a user from the guild. - try: - await target.send( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_softbanned_author"], - description=CONST.STRINGS["mod_softban_dm"].format( - target.name, - ctx.guild.name, - output_reason, + Parameters + ---------- + target: discord.Member + The user to softban. + reason: str | None + The reason for the softban. Defaults to None. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user + + bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id)) + await async_actionable(target, cast(discord.Member, ctx.author), bot_member) + + output_reason = reason or CONST.STRINGS["mod_no_reason"] + + try: + await target.send( + embed=Builder.create_embed( + theme="warning", + user_name=target.name, + author_text=CONST.STRINGS["mod_softbanned_author"], + description=CONST.STRINGS["mod_softban_dm"].format( + target.name, + ctx.guild.name, + output_reason, + ), + hide_name_in_description=True, ), - show_name=False, + ) + dm_sent = True + except (discord.HTTPException, discord.Forbidden): + dm_sent = False + + await ctx.guild.ban( + target, + reason=CONST.STRINGS["mod_reason"].format( + ctx.author.name, + lib.format.shorten(output_reason, 200), + ), + delete_message_seconds=86400, + ) + + await ctx.guild.unban( + target, + reason=CONST.STRINGS["mod_softban_unban_reason"].format( + ctx.author.name, ), ) - dm_sent = True - except (discord.HTTPException, discord.Forbidden): - dm_sent = False - await ctx.guild.ban( - target, - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - delete_message_seconds=86400, - ) + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=target.name, + author_text=CONST.STRINGS["mod_softbanned_author"], + description=CONST.STRINGS["mod_softbanned_user"].format(target.name), + footer_text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"], + ), + ) - await ctx.guild.unban( - target, - reason=CONST.STRINGS["mod_softban_unban_reason"].format( - ctx.author.name, - ), - ) + create_case_task = create_case(ctx, cast(discord.User, target), "SOFTBAN", reason) + await asyncio.gather(respond_task, create_case_task, return_exceptions=True) - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_softbanned_author"], - description=CONST.STRINGS["mod_softbanned_user"].format(target.name), - footer_text=CONST.STRINGS["mod_dm_sent"] - if dm_sent - else CONST.STRINGS["mod_dm_not_sent"], - ), - ) - target_user = await UserConverter().convert(ctx, str(target.id)) - create_case_task = create_case(ctx, target_user, "SOFTBAN", reason) - await asyncio.gather(respond_task, create_case_task, return_exceptions=True) +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Softban(bot)) diff --git a/modules/moderation/timeout.py b/modules/moderation/timeout.py index 5075f8e..a7da908 100644 --- a/modules/moderation/timeout.py +++ b/modules/moderation/timeout.py @@ -1,103 +1,163 @@ import asyncio import datetime -from typing import Optional +from typing import cast import discord -from discord.ext.commands import UserConverter, MemberConverter +from discord.ext import commands -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.formatter import format_duration_to_seconds, format_seconds_to_duration_string -from modules.moderation.utils.actionable import async_actionable -from modules.moderation.utils.case_handler import create_case +import lib.format +from lib.actionable import async_actionable +from lib.case_handler import create_case +from lib.const import CONST +from lib.exceptions import LumiException +from ui.embeds import Builder -async def timeout_user( - cog, - ctx, - target: discord.Member, - duration: str, - reason: Optional[str] = None, -): - bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) - await async_actionable(target, ctx.author, bot_member) +class Timeout(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.timeout.usage = lib.format.generate_usage(self.timeout) + self.untimeout.usage = lib.format.generate_usage(self.untimeout) - output_reason = reason or CONST.STRINGS["mod_no_reason"] + @commands.hybrid_command(name="timeout", aliases=["t", "to"]) + @commands.has_permissions(moderate_members=True) + @commands.bot_has_permissions(moderate_members=True) + @commands.guild_only() + async def timeout( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member, + duration: str, + reason: str | None = None, + ) -> None: + """ + Timeout a user in the guild. - # Parse duration to minutes and validate - duration_int = format_duration_to_seconds(duration) - duration_str = format_seconds_to_duration_string(duration_int) + Parameters + ---------- + target: discord.Member + The member to timeout. + duration: str + The duration of the timeout. Can be in the format of "1d2h3m4s". + reason: str | None + The reason for the timeout. Defaults to None. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user - await target.timeout_for( - duration=datetime.timedelta(seconds=duration_int), - reason=CONST.STRINGS["mod_reason"].format( - ctx.author.name, - formatter.shorten(output_reason, 200), - ), - ) + # Parse duration to minutes and validate + duration_int = lib.format.format_duration_to_seconds(duration) + duration_str = lib.format.format_seconds_to_duration_string(duration_int) - dm_task = target.send( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_timed_out_author"], - description=CONST.STRINGS["mod_timeout_dm"].format( - target.name, - ctx.guild.name, - duration_str, - output_reason, - ), - show_name=False, - ), - ) + # if longer than 27 days, return LumiException + if duration_int > 2332800: + raise LumiException(CONST.STRINGS["mod_timeout_too_long"]) - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_timed_out_author"], - description=CONST.STRINGS["mod_timed_out_user"].format(target.name), - ), - ) + bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id)) + await async_actionable(target, cast(discord.Member, ctx.author), bot_member) - target_user = await UserConverter().convert(ctx, str(target.id)) - create_case_task = create_case(ctx, target_user, "TIMEOUT", reason, duration_int) + output_reason = reason or CONST.STRINGS["mod_no_reason"] - await asyncio.gather( - dm_task, - respond_task, - create_case_task, - return_exceptions=True, - ) - - -async def untimeout_user(ctx, target: discord.Member, reason: Optional[str] = None): - output_reason = reason or CONST.STRINGS["mod_no_reason"] - - try: - await target.remove_timeout( + await target.timeout( + datetime.timedelta(seconds=duration_int), reason=CONST.STRINGS["mod_reason"].format( ctx.author.name, - formatter.shorten(output_reason, 200), + lib.format.shorten(output_reason, 200), ), ) - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_untimed_out_author"], - description=CONST.STRINGS["mod_untimed_out"].format(target.name), + dm_task = target.send( + embed=Builder.create_embed( + theme="warning", + user_name=target.name, + author_text=CONST.STRINGS["mod_timed_out_author"], + description=CONST.STRINGS["mod_timeout_dm"].format( + target.name, + ctx.guild.name, + duration_str, + output_reason, + ), + hide_name_in_description=True, ), ) - target_user = await UserConverter().convert(ctx, str(target.id)) - create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason) - await asyncio.gather(respond_task, create_case_task, return_exceptions=True) - - except discord.HTTPException: - return await ctx.respond( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_not_timed_out_author"], - description=CONST.STRINGS["mod_not_timed_out"].format(target.name), + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=target.name, + author_text=CONST.STRINGS["mod_timed_out_author"], + description=CONST.STRINGS["mod_timed_out_user"].format(target.name), ), ) + + create_case_task = create_case(ctx, cast(discord.User, target), "TIMEOUT", reason, duration_int) + + await asyncio.gather( + dm_task, + respond_task, + create_case_task, + return_exceptions=True, + ) + + @commands.hybrid_command(name="untimeout", aliases=["ut", "rto"]) + @commands.has_permissions(moderate_members=True) + @commands.bot_has_permissions(moderate_members=True) + @commands.guild_only() + async def untimeout( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member, + reason: str | None = None, + ) -> None: + """ + Untimeout a user in the guild. + + Parameters + ---------- + target: discord.Member + The member to untimeout. + reason: str | None + The reason for the untimeout. Defaults to None. + """ + assert ctx.guild + assert ctx.author + assert ctx.bot.user + + output_reason = reason or CONST.STRINGS["mod_no_reason"] + + try: + await target.timeout( + None, + reason=CONST.STRINGS["mod_reason"].format( + ctx.author.name, + lib.format.shorten(output_reason, 200), + ), + ) + + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_untimed_out_author"], + description=CONST.STRINGS["mod_untimed_out"].format(target.name), + ), + ) + + target_user = await commands.UserConverter().convert(ctx, str(target.id)) + create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason) + await asyncio.gather(respond_task, create_case_task, return_exceptions=True) + + except discord.HTTPException: + await ctx.send( + embed=Builder.create_embed( + theme="warning", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_not_timed_out_author"], + description=CONST.STRINGS["mod_not_timed_out"].format(target.name), + ), + ) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Timeout(bot)) diff --git a/modules/moderation/warn.py b/modules/moderation/warn.py index 6b50f57..c10807e 100644 --- a/modules/moderation/warn.py +++ b/modules/moderation/warn.py @@ -1,48 +1,82 @@ import asyncio -from typing import Optional +from typing import cast import discord -from discord.ext.commands import UserConverter, MemberConverter +from discord.ext import commands -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from modules.moderation.utils.actionable import async_actionable -from modules.moderation.utils.case_handler import create_case +import lib.format +from lib.actionable import async_actionable +from lib.case_handler import create_case +from lib.const import CONST +from lib.exceptions import LumiException +from ui.embeds import Builder -async def warn_user(ctx, target: discord.Member, reason: Optional[str]): - bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) - await async_actionable(target, ctx.author, bot_member) +class Warn(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.warn.usage = lib.format.generate_usage(self.warn) - output_reason = reason or CONST.STRINGS["mod_no_reason"] + @commands.hybrid_command(name="warn", aliases=["w"]) + @commands.has_permissions(manage_messages=True) + @commands.guild_only() + async def warn( + self, + ctx: commands.Context[commands.Bot], + target: discord.Member, + *, + reason: str | None = None, + ) -> None: + """ + Warn a user. - dm_task = target.send( - embed=EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["mod_warned_author"], - description=CONST.STRINGS["mod_warn_dm"].format( - target.name, - ctx.guild.name, - output_reason, + Parameters + ---------- + target: discord.Member + The user to warn. + reason: str | None + The reason for the warn. Defaults to None. + """ + if not ctx.guild or not ctx.author or not ctx.bot.user: + raise LumiException + + bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user)) + await async_actionable(target, cast(discord.Member, ctx.author), bot_member) + + output_reason = reason or CONST.STRINGS["mod_no_reason"] + + dm_task = target.send( + embed=Builder.create_embed( + theme="info", + user_name=target.name, + author_text=CONST.STRINGS["mod_warned_author"], + description=CONST.STRINGS["mod_warn_dm"].format( + target.name, + ctx.guild.name, + output_reason, + ), + hide_name_in_description=True, ), - show_name=False, - ), - ) + ) - respond_task = ctx.respond( - embed=EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["mod_warned_author"], - description=CONST.STRINGS["mod_warned_user"].format(target.name), - ), - ) + respond_task = ctx.send( + embed=Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["mod_warned_author"], + description=CONST.STRINGS["mod_warned_user"].format(target.name), + ), + ) - target_user = await UserConverter().convert(ctx, str(target.id)) - create_case_task = create_case(ctx, target_user, "WARN", reason) + create_case_task = create_case(ctx, cast(discord.User, target), "WARN", reason) - await asyncio.gather( - dm_task, - respond_task, - create_case_task, - return_exceptions=True, - ) + await asyncio.gather( + dm_task, + respond_task, + create_case_task, + return_exceptions=True, + ) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Warn(bot)) diff --git a/modules/triggers/__init__.py b/modules/triggers/__init__.py index 7a664d5..e69de29 100644 --- a/modules/triggers/__init__.py +++ b/modules/triggers/__init__.py @@ -1,81 +0,0 @@ -import discord -from discord.commands import SlashCommandGroup -from discord.ext import commands -from discord.ext.commands import guild_only - -from Client import LumiBot -from modules.triggers.add import add_reaction -from modules.triggers.delete import delete_reaction -from modules.triggers.list import list_reactions - - -class Triggers(commands.Cog): - def __init__(self, client: LumiBot): - self.client = client - - trigger = SlashCommandGroup( - "trigger", - "Manage custom reactions.", - default_member_permissions=discord.Permissions(manage_guild=True), - contexts={discord.InteractionContextType.guild}, - ) - add = trigger.create_subgroup("add", "Add new custom reactions.") - - @add.command( - name="response", - description="Add a new custom text reaction.", - help="Add a new custom text reaction to the database.", - ) - @guild_only() - async def add_text_reaction_command( - self, - ctx, - trigger_text: str, - response: str, - is_full_match: bool, - ): - await add_reaction(ctx, trigger_text, response, None, False, is_full_match) - - @add.command( - name="emoji", - description="Add a new custom emoji reaction.", - help="Add a new custom emoji reaction to the database.", - ) - @guild_only() - async def add_emoji_reaction_command( - self, - ctx, - trigger_text: str, - emoji: discord.Emoji, - is_full_match: bool, - ): - await add_reaction(ctx, trigger_text, None, emoji.id, True, is_full_match) - - @trigger.command( - name="delete", - description="Delete an existing custom reaction.", - help="Delete an existing custom reaction from the database.", - ) - @guild_only() - async def delete_reaction_command( - self, - ctx, - reaction_id: int, - ): - await delete_reaction(ctx, reaction_id) - - @trigger.command( - name="list", - description="List all custom reactions.", - help="List all custom reactions for the current guild.", - ) - @guild_only() - async def list_reactions_command( - self, - ctx, - ): - await list_reactions(ctx) - - -def setup(client: LumiBot): - client.add_cog(Triggers(client)) diff --git a/modules/triggers/add.py b/modules/triggers/add.py deleted file mode 100644 index 81a9819..0000000 --- a/modules/triggers/add.py +++ /dev/null @@ -1,107 +0,0 @@ -from typing import Optional - -from discord.ext import bridge - -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException -from services.reactions_service import CustomReactionsService - - -async def add_reaction( - ctx: bridge.Context, - trigger_text: str, - response: Optional[str], - emoji_id: Optional[int], - is_emoji: bool, - is_full_match: bool, -) -> None: - if ctx.guild is None: - return - - reaction_service = CustomReactionsService() - guild_id: int = ctx.guild.id - creator_id: int = ctx.author.id - - if not await check_reaction_limit( - reaction_service, - guild_id, - ): - return - - if not await check_existing_trigger( - reaction_service, - guild_id, - trigger_text, - ): - return - - success: bool = await reaction_service.create_custom_reaction( - guild_id=guild_id, - creator_id=creator_id, - trigger_text=trigger_text, - response=response, - emoji_id=emoji_id, - is_emoji=is_emoji, - is_full_match=is_full_match, - is_global=False, - ) - - if not success: - raise LumiException(CONST.STRINGS["triggers_not_added"]) - - trigger_text = formatter.shorten(trigger_text, 50) - - if response: - response = formatter.shorten(response, 50) - - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["triggers_add_author"], - description="", - footer_text=CONST.STRINGS["triggers_reaction_service_footer"], - show_name=False, - ) - - embed.description += CONST.STRINGS["triggers_add_description"].format( - trigger_text, - CONST.STRINGS["triggers_type_emoji"] - if is_emoji - else CONST.STRINGS["triggers_type_text"], - is_full_match, - ) - - if is_emoji: - embed.description += CONST.STRINGS["triggers_add_emoji_details"].format( - emoji_id, - ) - else: - embed.description += CONST.STRINGS["triggers_add_text_details"].format(response) - - await ctx.respond(embed=embed) - - -async def check_reaction_limit( - reaction_service: CustomReactionsService, - guild_id: int, -) -> bool: - limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100 - - if limit_reached: - raise LumiException(CONST.STRINGS["trigger_limit_reached"]) - - return True - - -async def check_existing_trigger( - reaction_service: CustomReactionsService, - guild_id: int, - trigger_text: str, -) -> bool: - existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text) - - if existing_trigger: - raise LumiException(CONST.STRINGS["trigger_already_exists"]) - - return True diff --git a/modules/triggers/delete.py b/modules/triggers/delete.py deleted file mode 100644 index 91fe0c5..0000000 --- a/modules/triggers/delete.py +++ /dev/null @@ -1,29 +0,0 @@ -from discord.ext import bridge - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException -from services.reactions_service import CustomReactionsService - - -async def delete_reaction(ctx: bridge.Context, reaction_id: int) -> None: - if ctx.guild is None: - return - - reaction_service = CustomReactionsService() - guild_id: int = ctx.guild.id - reaction = await reaction_service.find_id(reaction_id) - - if reaction is None or reaction["guild_id"] != guild_id or reaction["is_global"]: - raise LumiException(CONST.STRINGS["triggers_not_found"]) - - await reaction_service.delete_custom_reaction(reaction_id) - - embed = EmbedBuilder.create_success_embed( - ctx, - author_text=CONST.STRINGS["triggers_delete_author"], - description=CONST.STRINGS["triggers_delete_description"], - footer_text=CONST.STRINGS["triggers_reaction_service_footer"], - ) - - await ctx.respond(embed=embed) diff --git a/modules/triggers/list.py b/modules/triggers/list.py deleted file mode 100644 index 6dcf064..0000000 --- a/modules/triggers/list.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Any, Dict, List - -import discord -from discord.ext import bridge, pages - -from lib import formatter -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from services.reactions_service import CustomReactionsService - - -async def list_reactions(ctx: bridge.Context) -> None: - if ctx.guild is None: - return - - reaction_service: CustomReactionsService = CustomReactionsService() - guild_id: int = ctx.guild.id - - reactions: List[Dict[str, Any]] = await reaction_service.find_all_by_guild(guild_id) - if not reactions: - embed: discord.Embed = EmbedBuilder.create_warning_embed( - ctx, - author_text=CONST.STRINGS["triggers_no_reactions_title"], - description=CONST.STRINGS["triggers_no_reactions_description"], - footer_text=CONST.STRINGS["triggers_reaction_service_footer"], - show_name=False, - ) - await ctx.respond(embed=embed) - return - - pages_list = [] - for reaction in reactions: - embed = EmbedBuilder.create_success_embed( - ctx, - title=CONST.STRINGS["triggers_list_custom_reaction_id"].format( - reaction["id"], - ), - author_text=CONST.STRINGS["triggers_list_custom_reactions_title"], - footer_text=CONST.STRINGS["triggers_reaction_service_footer"], - show_name=False, - ) - - description_lines = [ - CONST.STRINGS["triggers_list_trigger_text"].format( - formatter.shorten(reaction["trigger_text"], 50), - ), - CONST.STRINGS["triggers_list_reaction_type"].format( - CONST.STRINGS["triggers_type_emoji"] - if reaction["is_emoji"] - else CONST.STRINGS["triggers_type_text"], - ), - ] - - if reaction["is_emoji"]: - description_lines.append( - CONST.STRINGS["triggers_list_emoji_id"].format(reaction["emoji_id"]), - ) - else: - description_lines.append( - CONST.STRINGS["triggers_list_response"].format( - formatter.shorten(reaction["response"], 50), - ), - ) - - description_lines.extend( - [ - CONST.STRINGS["triggers_list_full_match"].format( - "True" if reaction["is_full_match"] else "False", - ), - CONST.STRINGS["triggers_list_usage_count"].format( - reaction["usage_count"], - ), - ], - ) - - embed.description = "\n".join(description_lines) - pages_list.append(embed) - - paginator: pages.Paginator = pages.Paginator(pages=pages_list, timeout=180.0) - await paginator.respond(ctx, ephemeral=False) diff --git a/modules/triggers/triggers.py b/modules/triggers/triggers.py new file mode 100644 index 0000000..a79d8b4 --- /dev/null +++ b/modules/triggers/triggers.py @@ -0,0 +1,286 @@ +from typing import Any + +import discord +from discord import app_commands +from discord.ext import commands +from reactionmenu import ViewButton, ViewMenu + +import lib.format +from lib.const import CONST +from lib.exceptions import LumiException +from services.reactions_service import CustomReactionsService +from ui.embeds import Builder + + +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +class Triggers(commands.GroupCog, group_name="trigger"): + def __init__(self, bot: commands.Bot): + self.bot = bot + + add = app_commands.Group( + name="add", + description="Add a trigger", + allowed_contexts=app_commands.AppCommandContext( + guild=True, + dm_channel=False, + private_channel=False, + ), + default_permissions=discord.Permissions(manage_guild=True), + ) + + @add.command(name="response") + async def add_text_response( + self, + interaction: discord.Interaction, + trigger_text: str, + response: str, + is_full_match: bool = False, + ) -> None: + """ + Add a custom reaction that uses text. + + Parameters + ---------- + trigger_text: str + The text that triggers the reaction. + response: str + The text to respond with. + """ + assert interaction.guild + + reaction_service = CustomReactionsService() + guild_id: int = interaction.guild.id + creator_id: int = interaction.user.id + + limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100 + if limit_reached: + raise LumiException(CONST.STRINGS["trigger_limit_reached"]) + + existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text) + if existing_trigger: + raise LumiException(CONST.STRINGS["trigger_already_exists"]) + + success: bool = await reaction_service.create_custom_reaction( + guild_id=guild_id, + creator_id=creator_id, + trigger_text=trigger_text, + response=response, + emoji_id=None, + is_emoji=False, + is_full_match=is_full_match, + is_global=False, + ) + + if not success: + raise LumiException(CONST.STRINGS["triggers_not_added"]) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["triggers_add_author"], + description="", + footer_text=CONST.STRINGS["triggers_reaction_service_footer"], + hide_name_in_description=True, + ) + + embed.description += CONST.STRINGS["triggers_add_description"].format( + lib.format.shorten(trigger_text, 50), + CONST.STRINGS["triggers_type_text"], + is_full_match, + ) + embed.description += CONST.STRINGS["triggers_add_text_details"].format(lib.format.shorten(response, 50)) + + await interaction.response.send_message(embed=embed) + + @add.command(name="emoji") + async def add_emoji_response( + self, + interaction: discord.Interaction, + trigger_text: str, + emoji_id: int, + is_full_match: bool = False, + ) -> None: + """ + Add a custom reaction that uses an emoji. + + Parameters + ---------- + trigger_text: str + The text that triggers the reaction. + emoji_id: int + The ID of the emoji to use. + """ + assert interaction.guild + + reaction_service = CustomReactionsService() + guild_id: int = interaction.guild.id + creator_id: int = interaction.user.id + + limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100 + if limit_reached: + raise LumiException(CONST.STRINGS["trigger_limit_reached"]) + + existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text) + if existing_trigger: + raise LumiException(CONST.STRINGS["trigger_already_exists"]) + + success: bool = await reaction_service.create_custom_reaction( + guild_id=guild_id, + creator_id=creator_id, + trigger_text=trigger_text, + response=None, + emoji_id=emoji_id, + is_emoji=True, + is_full_match=is_full_match, + is_global=False, + ) + + if not success: + raise LumiException(CONST.STRINGS["triggers_not_added"]) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["triggers_add_author"], + description="", + footer_text=CONST.STRINGS["triggers_reaction_service_footer"], + hide_name_in_description=True, + ) + + embed.description += CONST.STRINGS["triggers_add_description"].format( + lib.format.shorten(trigger_text, 50), + CONST.STRINGS["triggers_type_emoji"], + is_full_match, + ) + embed.description += CONST.STRINGS["triggers_add_emoji_details"].format(emoji_id) + + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="delete") + async def remove_text_response( + self, + interaction: discord.Interaction, + reaction_id: int, + ) -> None: + """ + Delete a custom reaction by its ID. + + Parameters + ---------- + reaction_id: int + The ID of the reaction to delete. + """ + assert interaction.guild + + reaction_service = CustomReactionsService() + guild_id: int = interaction.guild.id + reaction = await reaction_service.find_id(reaction_id) + + if reaction is None or reaction["guild_id"] != guild_id or reaction["is_global"]: + raise LumiException(CONST.STRINGS["triggers_not_found"]) + + await reaction_service.delete_custom_reaction(reaction_id) + + embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + author_text=CONST.STRINGS["triggers_delete_author"], + description=CONST.STRINGS["triggers_delete_description"], + footer_text=CONST.STRINGS["triggers_reaction_service_footer"], + ) + + await interaction.response.send_message(embed=embed) + + @app_commands.command(name="list") + async def list_reactions(self, interaction: discord.Interaction) -> None: + """ + List all custom reactions for the current guild. + + Parameters + ---------- + interaction: discord.Interaction + The interaction to list the reactions for. + """ + assert interaction.guild + reaction_service: CustomReactionsService = CustomReactionsService() + guild_id: int = interaction.guild.id + + reactions: list[dict[str, Any]] = await reaction_service.find_all_by_guild(guild_id) + if not reactions: + embed: discord.Embed = Builder.create_embed( + theme="warning", + user_name=interaction.user.name, + author_text=CONST.STRINGS["triggers_no_reactions_title"], + description=CONST.STRINGS["triggers_no_reactions_description"], + footer_text=CONST.STRINGS["triggers_reaction_service_footer"], + hide_name_in_description=True, + ) + await interaction.response.send_message(embed=embed) + return + + menu = ViewMenu(interaction, menu_type=ViewMenu.TypeEmbed, all_can_click=True, remove_items_on_timeout=True) + + for reaction in reactions: + embed: discord.Embed = Builder.create_embed( + theme="success", + user_name=interaction.user.name, + title=CONST.STRINGS["triggers_list_custom_reaction_id"].format( + reaction["id"], + ), + author_text=CONST.STRINGS["triggers_list_custom_reactions_title"], + footer_text=CONST.STRINGS["triggers_reaction_service_footer"], + hide_name_in_description=True, + ) + + description_lines = [ + CONST.STRINGS["triggers_list_trigger_text"].format( + lib.format.shorten(reaction["trigger_text"], 50), + ), + CONST.STRINGS["triggers_list_reaction_type"].format( + CONST.STRINGS["triggers_type_emoji"] + if reaction["is_emoji"] + else CONST.STRINGS["triggers_type_text"], + ), + ] + + if reaction["is_emoji"]: + description_lines.append( + CONST.STRINGS["triggers_list_emoji_id"].format(reaction["emoji_id"]), + ) + else: + description_lines.append( + CONST.STRINGS["triggers_list_response"].format( + lib.format.shorten(reaction["response"], 50), + ), + ) + + description_lines.extend( + [ + CONST.STRINGS["triggers_list_full_match"].format( + "True" if reaction["is_full_match"] else "False", + ), + CONST.STRINGS["triggers_list_usage_count"].format( + reaction["usage_count"], + ), + ], + ) + + embed.description = "\n".join(description_lines) + menu.add_page(embed) + + buttons = [ + (ViewButton.ID_GO_TO_FIRST_PAGE, "⏮️"), + (ViewButton.ID_PREVIOUS_PAGE, "⏪"), + (ViewButton.ID_NEXT_PAGE, "⏩"), + (ViewButton.ID_GO_TO_LAST_PAGE, "⏭️"), + ] + + for custom_id, emoji in buttons: + menu.add_button(ViewButton(style=discord.ButtonStyle.secondary, custom_id=custom_id, emoji=emoji)) + + await menu.start() + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Triggers(bot)) diff --git a/poetry.lock b/poetry.lock index dabad4b..29b03ab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,43 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiocache" +version = "0.12.2" +description = "multi backend asyncio cache" +optional = false +python-versions = "*" +files = [ + {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, + {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, +] + +[package.extras] +memcached = ["aiomcache (>=0.5.2)"] +msgpack = ["msgpack (>=0.5.5)"] +redis = ["redis (>=4.2.0)"] + +[[package]] +name = "aioconsole" +version = "0.7.1" +description = "Asynchronous console and interfaces for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aioconsole-0.7.1-py3-none-any.whl", hash = "sha256:1867a7cc86897a87398e6e6fba302738548f1cf76cbc6c76e06338e091113bdc"}, + {file = "aioconsole-0.7.1.tar.gz", hash = "sha256:a3e52428d32623c96746ec3862d97483c61c12a2f2dfba618886b709415d4533"}, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "aiohappyeyeballs" version = "2.4.0" @@ -136,6 +174,17 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.4.0" @@ -177,13 +226,13 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "certifi" -version = "2024.7.4" +version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] [[package]] @@ -307,6 +356,26 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "discord-py" +version = "2.4.0" +description = "A Python wrapper for the Discord API" +optional = false +python-versions = ">=3.8" +files = [ + {file = "discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d"}, + {file = "discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5"}, +] + +[package.dependencies] +aiohttp = ">=3.7.4,<4" + +[package.extras] +docs = ["sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "typing-extensions (>=4.3,<5)"] +speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"] +test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata"] +voice = ["PyNaCl (>=1.3.0,<1.6)"] + [[package]] name = "distlib" version = "0.3.8" @@ -471,13 +540,13 @@ trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] @@ -492,6 +561,7 @@ brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "identify" @@ -762,33 +832,137 @@ files = [ test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] [[package]] -name = "py-cord" -version = "2.6.0" -description = "A Python wrapper for the Discord API" +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "py_cord-2.6.0-py3-none-any.whl", hash = "sha256:906cc077904a4d478af0264ae4374b0412ed9f9ab950d0162c4f31239a906d26"}, - {file = "py_cord-2.6.0.tar.gz", hash = "sha256:bbc0349542965d05e4b18cc4424136206430a8cc911fda12a0a57df6fdf9cd9c"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] -aiohttp = ">=3.6.0,<4.0" +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] -docs = ["furo (==2023.3.23)", "myst-parser (==1.0.0)", "sphinx (==5.3.0)", "sphinx-autodoc-typehints (==1.23.0)", "sphinx-copybutton (==0.5.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "sphinxext-opengraph (==0.9.1)"] -speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"] -voice = ["PyNaCl (>=1.3.0,<1.6)"] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pyright" -version = "1.1.377" +version = "1.1.378" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162"}, - {file = "pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf"}, + {file = "pyright-1.1.378-py3-none-any.whl", hash = "sha256:8853776138b01bc284da07ac481235be7cc89d3176b073d2dba73636cb95be79"}, + {file = "pyright-1.1.378.tar.gz", hash = "sha256:78a043be2876d12d0af101d667e92c7734f3ebb9db71dccc2c220e7e7eb89ca2"}, ] [package.dependencies] @@ -798,20 +972,6 @@ nodeenv = ">=1.6.0" all = ["twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - [[package]] name = "pytimeparse" version = "1.1.8" @@ -823,17 +983,6 @@ files = [ {file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"}, ] -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -896,6 +1045,20 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "reactionmenu" +version = "3.1.7" +description = "A library to create a discord.py 2.0+ paginator. Supports pagination with buttons, reactions, and category selection using selects." +optional = false +python-versions = ">=3.8" +files = [ + {file = "reactionmenu-3.1.7-py3-none-any.whl", hash = "sha256:51a217c920382dfecbb2f05d60bd20b79ed9895e9f5663f6c0edb75e806f863a"}, + {file = "reactionmenu-3.1.7.tar.gz", hash = "sha256:10da3c1966de2b6264fcdf72537348923c5e151501644375c25f430bfd870463"}, +] + +[package.dependencies] +"discord.py" = ">=2.0.0" + [[package]] name = "requests" version = "2.32.3" @@ -919,29 +1082,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.5.7" +version = "0.6.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, + {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, + {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, + {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, + {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, + {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, + {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, ] [[package]] @@ -982,6 +1145,17 @@ files = [ ply = ">=3.4" six = ">=1.12.0" +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" version = "2.2.2" @@ -1035,101 +1209,103 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [[package]] name = "yarl" -version = "1.9.4" +version = "1.9.7" description = "Yet another URL library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, - {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, - {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, - {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, - {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, - {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, - {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, - {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, - {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, - {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, - {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, - {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, - {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, - {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, - {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, - {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, - {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, - {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, - {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, - {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, - {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, - {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, - {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, - {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, - {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, - {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, - {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, - {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, - {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, - {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, - {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, - {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, - {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, - {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, + {file = "yarl-1.9.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:60c04415b31a1611ef5989a6084dd6f6b95652c6a18378b58985667b65b2ecb6"}, + {file = "yarl-1.9.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1787dcfdbe730207acb454548a6e19f80ae75e6d2d1f531c5a777bc1ab6f7952"}, + {file = "yarl-1.9.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5ddad20363f9f1bbedc95789c897da62f939e6bc855793c3060ef8b9f9407bf"}, + {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdb156a06208fc9645ae7cc0fca45c40dd40d7a8c4db626e542525489ca81a9"}, + {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522fa3d300d898402ae4e0fa7c2c21311248ca43827dc362a667de87fdb4f1be"}, + {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7f9cabfb8b980791b97a3ae3eab2e38b2ba5eab1af9b7495bdc44e1ce7c89e3"}, + {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fc728857df4087da6544fc68f62d7017fa68d74201d5b878e18ed4822c31fb3"}, + {file = "yarl-1.9.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dba2ebac677184d56374fa3e452b461f5d6a03aa132745e648ae8859361eb6b"}, + {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a95167ae34667c5cc7d9206c024f793e8ffbadfb307d5c059de470345de58a21"}, + {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9d319ac113ca47352319cbea92d1925a37cb7bd61a8c2f3e3cd2e96eb33cccae"}, + {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2d71a5d818d82586ac46265ae01466e0bda0638760f18b21f1174e0dd58a9d2f"}, + {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ff03f1c1ac474c66d474929ae7e4dd195592c1c7cc8c36418528ed81b1ca0a79"}, + {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78250f635f221dde97d02c57aade3313310469bc291888dfe32acd1012594441"}, + {file = "yarl-1.9.7-cp310-cp310-win32.whl", hash = "sha256:f3aaf9fa960d55bd7876d55d7ea3cc046f3660df1ff73fc1b8c520a741ed1f21"}, + {file = "yarl-1.9.7-cp310-cp310-win_amd64.whl", hash = "sha256:e8362c941e07fbcde851597672a5e41b21dc292b7d5a1dc439b7a93c9a1af5d9"}, + {file = "yarl-1.9.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:596069ddeaf72b5eb36cd714dcd2b5751d0090d05a8d65113b582ed9e1c801fb"}, + {file = "yarl-1.9.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb870907e8b86b2f32541403da9455afc1e535ce483e579bea0e6e79a0cc751c"}, + {file = "yarl-1.9.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ca5e86be84492fa403c4dcd4dcaf8e1b1c4ffc747b5176f7c3d09878c45719b0"}, + {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a99cecfb51c84d00132db909e83ae388793ca86e48df7ae57f1be0beab0dcce5"}, + {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25508739e9b44d251172145f54c084b71747b09e4d237dc2abb045f46c36a66e"}, + {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60f3b5aec3146b6992640592856414870f5b20eb688c1f1d5f7ac010a7f86561"}, + {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1557456afce5db3d655b5f8a31cdcaae1f47e57958760525c44b76e812b4987"}, + {file = "yarl-1.9.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71bb1435a84688ed831220c5305d96161beb65cac4a966374475348aa3de4575"}, + {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f87d8645a7a806ec8f66aac5e3b1dcb5014849ff53ffe2a1f0b86ca813f534c7"}, + {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:58e3f01673873b8573da3abe138debc63e4e68541b2104a55df4c10c129513a4"}, + {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8af0bbd4d84f8abdd9b11be9488e32c76b1501889b73c9e2292a15fb925b378b"}, + {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7fc441408ed0d9c6d2d627a02e281c21f5de43eb5209c16636a17fc704f7d0f8"}, + {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a9552367dc440870556da47bb289a806f08ad06fbc4054072d193d9e5dd619ba"}, + {file = "yarl-1.9.7-cp311-cp311-win32.whl", hash = "sha256:628619008680a11d07243391271b46f07f13b75deb9fe92ef342305058c70722"}, + {file = "yarl-1.9.7-cp311-cp311-win_amd64.whl", hash = "sha256:bc23d870864971c8455cfba17498ccefa53a5719ea9f5fce5e7e9c1606b5755f"}, + {file = "yarl-1.9.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d8cf3d0b67996edc11957aece3fbce4c224d0451c7c3d6154ec3a35d0e55f6b"}, + {file = "yarl-1.9.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a7748cd66fef49c877e59503e0cc76179caf1158d1080228e67e1db14554f08"}, + {file = "yarl-1.9.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a6fa3aeca8efabb0fbbb3b15e0956b0cb77f7d9db67c107503c30af07cd9e00"}, + {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf37dd0008e5ac5c3880198976063c491b6a15b288d150d12833248cf2003acb"}, + {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87aa5308482f248f8c3bd9311cd6c7dfd98ea1a8e57e35fb11e4adcac3066003"}, + {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:867b13c1b361f9ba5d2f84dc5408082f5d744c83f66de45edc2b96793a9c5e48"}, + {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ce93947554c2c85fe97fc4866646ec90840bc1162e4db349b37d692a811755"}, + {file = "yarl-1.9.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcd3d94b848cba132f39a5b40d80b0847d001a91a6f35a2204505cdd46afe1b2"}, + {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d06d6a8f98dd87646d98f0c468be14b201e47ec6092ad569adf835810ad0dffb"}, + {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:91567ff4fce73d2e7ac67ed5983ad26ba2343bc28cb22e1e1184a9677df98d7c"}, + {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d5594512541e63188fea640b7f066c218d2176203d6e6f82abf702ae3dca3b2"}, + {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c2743e43183e4afbb07d5605693299b8756baff0b086c25236c761feb0e3c56"}, + {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:daa69a3a2204355af39f4cfe7f3870d87c53d77a597b5100b97e3faa9460428b"}, + {file = "yarl-1.9.7-cp312-cp312-win32.whl", hash = "sha256:36b16884336c15adf79a4bf1d592e0c1ffdb036a760e36a1361565b66785ec6c"}, + {file = "yarl-1.9.7-cp312-cp312-win_amd64.whl", hash = "sha256:2ead2f87a1174963cc406d18ac93d731fbb190633d3995fa052d10cefae69ed8"}, + {file = "yarl-1.9.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:808eddabcb6f7b2cdb6929b3e021ac824a2c07dc7bc83f7618e18438b1b65781"}, + {file = "yarl-1.9.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:395ab0d8ce6d104a988da429bcbfd445e03fb4c911148dfd523f69d13f772e47"}, + {file = "yarl-1.9.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49827dfccbd59c4499605c13805e947349295466e490860a855b7c7e82ec9c75"}, + {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b8bbdd425d0978311520ea99fb6c0e9e04e64aee84fac05f3157ace9f81b05"}, + {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71d33fd1c219b5b28ee98cd76da0c9398a4ed4792fd75c94135237db05ba5ca8"}, + {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62440431741d0b7d410e5cbad800885e3289048140a43390ecab4f0b96dde3bb"}, + {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db97210433366dfba55590e48285b89ad0146c52bf248dd0da492dd9f0f72cf"}, + {file = "yarl-1.9.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:653597b615809f2e5f4dba6cd805608b6fd3597128361a22cc612cf7c7a4d1bf"}, + {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df47612129e66f7ce7c9994d4cd4e6852f6e3bf97699375d86991481796eeec8"}, + {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5e338b6febbae6c9fe86924bac3ea9c1944e33255c249543cd82a4af6df6047b"}, + {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e649d37d04665dddb90994bbf0034331b6c14144cc6f3fbce400dc5f28dc05b7"}, + {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0a1b8fd849567be56342e988e72c9d28bd3c77b9296c38b9b42d2fe4813c9d3f"}, + {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9d715b2175dff9a49c6dafdc2ab3f04850ba2f3d4a77f69a5a1786b057a9d45"}, + {file = "yarl-1.9.7-cp313-cp313-win32.whl", hash = "sha256:bc9233638b07c2e4a3a14bef70f53983389bffa9e8cb90a2da3f67ac9c5e1842"}, + {file = "yarl-1.9.7-cp313-cp313-win_amd64.whl", hash = "sha256:62e110772330d7116f91e79cd83fef92545cb2f36414c95881477aa01971f75f"}, + {file = "yarl-1.9.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a564155cc2194ecd9c0d8f8dc57059b822a507de5f08120063675eb9540576aa"}, + {file = "yarl-1.9.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03e917cc44a01e1be60a83ee1a17550b929490aaa5df2a109adc02137bddf06b"}, + {file = "yarl-1.9.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eefda67ba0ba44ab781e34843c266a76f718772b348f7c5d798d8ea55b95517f"}, + {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316c82b499b6df41444db5dea26ee23ece9356e38cea43a8b2af9e6d8a3558e4"}, + {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10452727843bc847596b75e30a7fe92d91829f60747301d1bd60363366776b0b"}, + {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:050f3e4d886be55728fef268587d061c5ce6f79a82baba71840801b63441c301"}, + {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0aabe557446aa615693a82b4d3803c102fd0e7a6a503bf93d744d182a510184"}, + {file = "yarl-1.9.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23404842228e6fa8ace235024519df37f3f8e173620407644d40ddca571ff0f4"}, + {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:34736fcc9d6d7080ebbeb0998ecb91e4f14ad8f18648cf0b3099e2420a225d86"}, + {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:48f7a158f3ca67509d21cb02a96964e4798b6f133691cc0c86cf36e26e26ec8f"}, + {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6639444d161c693cdabb073baaed1945c717d3982ecedf23a219bc55a242e728"}, + {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1cd450e10cb53d63962757c3f6f7870be49a3e448c46621d6bd46f8088d532de"}, + {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74d3ef5e81f81507cea04bf5ae22f18ef538607a7c754aac2b6e3029956a2842"}, + {file = "yarl-1.9.7-cp38-cp38-win32.whl", hash = "sha256:4052dbd0c900bece330e3071c636f99dff06e4628461a29b38c6e222a427cf98"}, + {file = "yarl-1.9.7-cp38-cp38-win_amd64.whl", hash = "sha256:dd08da4f2d171e19bd02083c921f1bef89f8f5f87000d0ffc49aa257bc5a9802"}, + {file = "yarl-1.9.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ab906a956d2109c6ea11e24c66592b06336e2743509290117f0f7f47d2c1dd3"}, + {file = "yarl-1.9.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d8ad761493d5aaa7ab2a09736e62b8a220cb0b10ff8ccf6968c861cd8718b915"}, + {file = "yarl-1.9.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d35f9cdab0ec5e20cf6d2bd46456cf599052cf49a1698ef06b9592238d1cf1b1"}, + {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a48d2b9f0ae29a456fb766ae461691378ecc6cf159dd9f938507d925607591c3"}, + {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf85599c9336b89b92c313519bcaa223d92fa5d98feb4935a47cce2e8722b4b8"}, + {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e8916b1ff7680b1f2b1608c82dc15c569b9f2cb2da100c747c291f1acf18a14"}, + {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29c80890e0a64fb0e5f71350d48da330995073881f8b8e623154aef631febfb0"}, + {file = "yarl-1.9.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9163d21aa40ff8528db2aee2b0b6752efe098055b41ab8e5422b2098457199fe"}, + {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:65e3098969baf221bb45e3b2f60735fc2b154fc95902131ebc604bae4c629ea6"}, + {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cddebd096effe4be90fd378e4224cd575ac99e1c521598a6900e94959006e02e"}, + {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8525f955a2dcc281573b6aadeb8ab9c37e2d3428b64ca6a2feec2a794a69c1da"}, + {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:5d585c7d834c13f24c7e3e0efaf1a4b7678866940802e11bd6c4d1f99c935e6b"}, + {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78805148e780a9ca66f3123e04741e344b66cf06b4fb13223e3a209f39a6da55"}, + {file = "yarl-1.9.7-cp39-cp39-win32.whl", hash = "sha256:3f53df493ec80b76969d6e1ae6e4411a55ab1360e02b80c84bd4b33d61a567ba"}, + {file = "yarl-1.9.7-cp39-cp39-win_amd64.whl", hash = "sha256:c81c28221a85add23a0922a6aeb2cdda7f9723e03e2dfae06fee5c57fe684262"}, + {file = "yarl-1.9.7-py3-none-any.whl", hash = "sha256:49935cc51d272264358962d050d726c3e5603a616f53e52ea88e9df1728aa2ee"}, + {file = "yarl-1.9.7.tar.gz", hash = "sha256:f28e602edeeec01fc96daf7728e8052bc2e12a672e2a138561a1ebaf30fd9df7"}, ] [package.dependencies] @@ -1139,4 +1315,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "541857707095fb0b5c439aedbfacd91ca3582f110f12d786dc29e7c70f989b3e" +content-hash = "70d489a46ab888e4ed82b7447d5a02cde51e9062b735715d98cc3e4f089aadb6" diff --git a/pyproject.toml b/pyproject.toml index aa73806..1a402e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,156 @@ [tool.poetry] -authors = ["wlinator "] +authors = ["wlinator "] description = "A Discord application, can serve as a template for your own bot." license = "GNU General Public License v3.0" -name = "lumi" +name = "luminara" package-mode = false readme = "README.md" -version = "0.1.0" +version = "3" [tool.poetry.dependencies] +aiocache = "^0.12.2" +aioconsole = "^0.7.1" +aiofiles = "^24.1.0" +discord-py = "^2.4.0" dropbox = "^12.0.2" -httpx = "^0.27.0" +httpx = "^0.27.2" loguru = "^0.7.2" mysql-connector-python = "^9.0.0" -pre-commit = "^3.7.1" +pre-commit = "^3.8.0" psutil = "^6.0.0" -py-cord = "^2.5.0" -pyright = "^1.1.371" +pydantic = "^2.8.2" +pyright = "^1.1.377" python = "^3.12" -python-dotenv = "^1.0.1" pytimeparse = "^1.1.8" -pytz = "^2024.1" -ruff = "^0.5.2" +pyyaml = "^6.0.2" +reactionmenu = "^3.1.7" +ruff = "^0.6.2" +typing-extensions = "^4.12.2" [build-system] build-backend = "poetry.core.masonry.api" requires = ["poetry-core"] + +[tool.ruff] +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "examples", + "tmp", + "tests", + ".archive", + "stubs", +] + +indent-width = 4 +line-length = 120 +target-version = "py312" + +# Ruff Linting Configuration +[tool.ruff.lint] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +fixable = ["ALL"] +ignore = ["E501", "N814", "PLR0913", "PLR2004"] +select = [ + "I", # isort + "E", # pycodestyle-error + "F", # pyflakes + "PERF", # perflint + "N", # pep8-naming + "TRY", # tryceratops + "UP", # pyupgrade + "FURB", # refurb + "PL", # pylint + "B", # flake8-bugbear + "SIM", # flake8-simplify + "ASYNC", # flake8-async + "A", # flake8-builtins + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "EM", # flake8-errmsg + "PIE", # flake8-pie + "T20", # flake8-print + "Q", # flake8-quotes + "RET", # flake8-return + "PTH", # flake8-use-pathlib + "INP", # flake8-no-pep420 + "RSE", # flake8-raise + "ICN", # flake8-import-conventions + "RUF", # ruff +] +unfixable = [] + +# Ruff Formatting Configuration +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = "dynamic" +indent-style = "space" +line-ending = "lf" +quote-style = "double" +skip-magic-trailing-comma = false + +# Pyright Configuration +[tool.pyright] +defineConstant = {DEBUG = true} +exclude = [ + ".direnv", + ".eggs", + ".git", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "examples", + "tests", + ".archive", + "stubs", +] +include = ["**/*.py"] +pythonPlatform = "Linux" +pythonVersion = "3.12" +reportMissingTypeStubs = true +reportShadowedImports = false +stubPath = "./stubs" +typeCheckingMode = "strict" +venv = ".venv" +venvPath = "." diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/birthday_service.py b/services/birthday_service.py index 5ec2c66..940329e 100644 --- a/services/birthday_service.py +++ b/services/birthday_service.py @@ -1,17 +1,16 @@ import datetime - -import pytz +from zoneinfo import ZoneInfo from db import database -class Birthday: - def __init__(self, user_id, guild_id): - self.user_id = user_id - self.guild_id = guild_id +class BirthdayService: + def __init__(self, user_id: int, guild_id: int) -> None: + self.user_id: int = user_id + self.guild_id: int = guild_id - def set(self, birthday): - query = """ + def set(self, birthday: datetime.date) -> None: + query: str = """ INSERT INTO birthdays (user_id, guild_id, birthday) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE birthday = VALUES(birthday); @@ -19,8 +18,8 @@ class Birthday: database.execute_query(query, (self.user_id, self.guild_id, birthday)) - def delete(self): - query = """ + def delete(self) -> None: + query: str = """ DELETE FROM birthdays WHERE user_id = %s AND guild_id = %s; """ @@ -28,27 +27,26 @@ class Birthday: database.execute_query(query, (self.user_id, self.guild_id)) @staticmethod - def get_birthdays_today(): - query = """ + def get_birthdays_today() -> list[tuple[int, int]]: + query: str = """ SELECT user_id, guild_id FROM birthdays WHERE DATE_FORMAT(birthday, '%m-%d') = %s """ - tz = pytz.timezone("US/Eastern") - today = datetime.datetime.now(tz).strftime("%m-%d") + today: str = datetime.datetime.now(ZoneInfo("US/Eastern")).strftime("%m-%d") return database.select_query(query, (today,)) @staticmethod - def get_upcoming_birthdays(guild_id): - query = """ + def get_upcoming_birthdays(guild_id: int) -> list[tuple[int, str]]: + query: str = """ SELECT user_id, DATE_FORMAT(birthday, '%m-%d') AS upcoming_birthday FROM birthdays WHERE guild_id = %s ORDER BY (DAYOFYEAR(birthday) - DAYOFYEAR(now()) + 366) % 366; """ - data = database.select_query(query, (guild_id,)) + data: list[tuple[int, str]] = database.select_query(query, (guild_id,)) - return [(row[0], row[1]) for row in data] + return [(int(row[0]), str(row[1])) for row in data] diff --git a/services/blacklist_service.py b/services/blacklist_service.py index 7ca40ac..24220d2 100644 --- a/services/blacklist_service.py +++ b/services/blacklist_service.py @@ -1,5 +1,3 @@ -from typing import List, Optional, Tuple - from db import database @@ -7,7 +5,7 @@ class BlacklistUserService: def __init__(self, user_id: int) -> None: self.user_id: int = user_id - def add_to_blacklist(self, reason: Optional[str] = None) -> None: + def add_to_blacklist(self, reason: str | None = None) -> None: """ Adds a user to the blacklist with the given reason. @@ -37,5 +35,5 @@ class BlacklistUserService: FROM blacklist_user WHERE user_id = %s """ - result: List[Tuple[bool]] = database.select_query(query, (user_id,)) + result: list[tuple[bool]] = database.select_query(query, (user_id,)) return any(active for (active,) in result) diff --git a/services/moderation/case_service.py b/services/case_service.py similarity index 64% rename from services/moderation/case_service.py rename to services/case_service.py index aaaf25b..2a8caf7 100644 --- a/services/moderation/case_service.py +++ b/services/case_service.py @@ -1,10 +1,10 @@ -from typing import Optional, Dict, Any, List +from typing import Any -from db.database import execute_query, select_query_one, select_query_dict +from db.database import execute_query, select_query_dict, select_query_one class CaseService: - def __init__(self): + def __init__(self) -> None: pass def create_case( @@ -13,10 +13,10 @@ class CaseService: target_id: int, moderator_id: int, action_type: str, - reason: Optional[str] = None, - duration: Optional[int] = None, - expires_at: Optional[str] = None, - modlog_message_id: Optional[int] = None, + reason: str | None = None, + duration: int | None = None, + expires_at: str | None = None, + modlog_message_id: int | None = None, ) -> int: # Get the next case number for the guild query: str = """ @@ -24,10 +24,11 @@ class CaseService: FROM cases WHERE guild_id = %s """ - case_number = select_query_one(query, (guild_id,)) + case_number: int | None = select_query_one(query, (guild_id,)) if case_number is None: - raise ValueError("Failed to retrieve the next case number.") + msg: str = "Failed to retrieve the next case number." + raise ValueError(msg) # Insert the new case query: str = """ @@ -54,8 +55,8 @@ class CaseService: return int(case_number) - def close_case(self, guild_id, case_number): - query = """ + def close_case(self, guild_id: int, case_number: int) -> None: + query: str = """ UPDATE cases SET is_closed = TRUE, updated_at = CURRENT_TIMESTAMP WHERE guild_id = %s AND case_number = %s @@ -66,9 +67,9 @@ class CaseService: self, guild_id: int, case_number: int, - new_reason: Optional[str] = None, + new_reason: str | None = None, ) -> bool: - query = """ + query: str = """ UPDATE cases SET reason = COALESCE(%s, reason), updated_at = CURRENT_TIMESTAMP @@ -84,88 +85,84 @@ class CaseService: ) return True - def edit_case(self, guild_id, case_number, changes: dict): - set_clause = ", ".join([f"{key} = %s" for key in changes.keys()]) - query = f""" + def edit_case(self, guild_id: int, case_number: int, changes: dict[str, Any]) -> None: + set_clause: str = ", ".join([f"{key} = %s" for key in changes]) + query: str = f""" UPDATE cases SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE guild_id = %s AND case_number = %s """ execute_query(query, (*changes.values(), guild_id, case_number)) - def fetch_case_by_id(self, case_id: int) -> Optional[Dict[str, Any]]: + def _fetch_cases(self, query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = select_query_dict(query, params) + return results + + def _fetch_single_case(self, query: str, params: tuple[Any, ...]) -> dict[str, Any] | None: + result = self._fetch_cases(query, params) + return result[0] if result else None + + def fetch_case_by_id(self, case_id: int) -> dict[str, Any] | None: query: str = """ SELECT * FROM cases WHERE id = %s LIMIT 1 """ - result: List[Dict[str, Any]] = select_query_dict(query, (case_id,)) - return result[0] if result else None + return self._fetch_single_case(query, (case_id,)) def fetch_case_by_guild_and_number( self, guild_id: int, case_number: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: query: str = """ SELECT * FROM cases WHERE guild_id = %s AND case_number = %s ORDER BY case_number DESC LIMIT 1 """ - result: List[Dict[str, Any]] = select_query_dict(query, (guild_id, case_number)) - return result[0] if result else None + return self._fetch_single_case(query, (guild_id, case_number)) - def fetch_cases_by_guild(self, guild_id: int) -> List[Dict[str, Any]]: + def fetch_cases_by_guild(self, guild_id: int) -> list[dict[str, Any]]: query: str = """ SELECT * FROM cases WHERE guild_id = %s ORDER BY case_number DESC """ - results: List[Dict[str, Any]] = select_query_dict(query, (guild_id,)) - return results + return self._fetch_cases(query, (guild_id,)) def fetch_cases_by_target( self, guild_id: int, target_id: int, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: query: str = """ SELECT * FROM cases WHERE guild_id = %s AND target_id = %s ORDER BY case_number DESC """ - results: List[Dict[str, Any]] = select_query_dict(query, (guild_id, target_id)) - return results + return self._fetch_cases(query, (guild_id, target_id)) def fetch_cases_by_moderator( self, guild_id: int, moderator_id: int, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: query: str = """ SELECT * FROM cases WHERE guild_id = %s AND moderator_id = %s ORDER BY case_number DESC """ - results: List[Dict[str, Any]] = select_query_dict( - query, - (guild_id, moderator_id), - ) - return results + return self._fetch_cases(query, (guild_id, moderator_id)) def fetch_cases_by_action_type( self, guild_id: int, action_type: str, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: query: str = """ SELECT * FROM cases WHERE guild_id = %s AND action_type = %s ORDER BY case_number DESC """ - results: List[Dict[str, Any]] = select_query_dict( - query, - (guild_id, action_type.upper()), - ) - return results + return self._fetch_cases(query, (guild_id, action_type.upper())) diff --git a/services/config_service.py b/services/config_service.py index b996c92..172224a 100644 --- a/services/config_service.py +++ b/services/config_service.py @@ -1,28 +1,30 @@ +from typing import Any + from db import database class GuildConfig: - def __init__(self, guild_id): - self.guild_id = guild_id - self.birthday_channel_id = None - self.command_channel_id = None - self.intro_channel_id = None - self.welcome_channel_id = None - self.welcome_message = None - self.boost_channel_id = None - self.boost_message = None - self.boost_image_url = None - self.level_channel_id = None - self.level_message = None - self.level_message_type = 1 + def __init__(self, guild_id: int) -> None: + self.guild_id: int = guild_id + self.birthday_channel_id: int | None = None + self.command_channel_id: int | None = None + self.intro_channel_id: int | None = None + self.welcome_channel_id: int | None = None + self.welcome_message: str | None = None + self.boost_channel_id: int | None = None + self.boost_message: str | None = None + self.boost_image_url: str | None = None + self.level_channel_id: int | None = None + self.level_message: str | None = None + self.level_message_type: int = 1 self.fetch_or_create_config() - def fetch_or_create_config(self): + def fetch_or_create_config(self) -> None: """ Gets a Guild Config from the database or inserts a new row if it doesn't exist yet. """ - query = """ + query: str = """ SELECT birthday_channel_id, command_channel_id, intro_channel_id, welcome_channel_id, welcome_message, boost_channel_id, boost_message, boost_image_url, level_channel_id, @@ -38,35 +40,24 @@ class GuildConfig: database.execute_query(query, (self.guild_id,)) # TODO Rename this here and in `fetch_or_create_config` - def _extracted_from_fetch_or_create_config_14(self, query): + def _extracted_from_fetch_or_create_config_14(self, query: str) -> None: + result: tuple[Any, ...] = database.select_query(query, (self.guild_id,))[0] ( - birthday_channel_id, - command_channel_id, - intro_channel_id, - welcome_channel_id, - welcome_message, - boost_channel_id, - boost_message, - boost_image_url, - level_channel_id, - level_message, - level_message_type, - ) = database.select_query(query, (self.guild_id,))[0] + self.birthday_channel_id, + self.command_channel_id, + self.intro_channel_id, + self.welcome_channel_id, + self.welcome_message, + self.boost_channel_id, + self.boost_message, + self.boost_image_url, + self.level_channel_id, + self.level_message, + self.level_message_type, + ) = result - self.birthday_channel_id = birthday_channel_id - self.command_channel_id = command_channel_id - self.intro_channel_id = intro_channel_id - self.welcome_channel_id = welcome_channel_id - self.welcome_message = welcome_message - self.boost_channel_id = boost_channel_id - self.boost_message = boost_message - self.boost_image_url = boost_image_url - self.level_channel_id = level_channel_id - self.level_message = level_message - self.level_message_type = level_message_type - - def push(self): - query = """ + def push(self) -> None: + query: str = """ UPDATE guild_config SET birthday_channel_id = %s, @@ -102,18 +93,18 @@ class GuildConfig: ) @staticmethod - def get_prefix(message): + def get_prefix(message: Any) -> str: """ Gets the prefix from a given guild. This function is done as static method to make the prefix fetch process faster. """ - query = """ + query: str = """ SELECT prefix FROM guild_config WHERE guild_id = %s """ - prefix = database.select_query_one( + prefix: str | None = database.select_query_one( query, (message.guild.id if message.guild else None,), ) @@ -121,8 +112,8 @@ class GuildConfig: return prefix or "." @staticmethod - def get_prefix_from_guild_id(guild_id): - query = """ + def get_prefix_from_guild_id(guild_id: int) -> str: + query: str = """ SELECT prefix FROM guild_config WHERE guild_id = %s @@ -131,11 +122,11 @@ class GuildConfig: return database.select_query_one(query, (guild_id,)) or "." @staticmethod - def set_prefix(guild_id, prefix): + def set_prefix(guild_id: int, prefix: str) -> None: """ Sets the prefix for a given guild. """ - query = """ + query: str = """ UPDATE guild_config SET prefix = %s WHERE guild_id = %s; diff --git a/services/currency_service.py b/services/currency_service.py index 314fd5d..de71790 100644 --- a/services/currency_service.py +++ b/services/currency_service.py @@ -4,21 +4,19 @@ from db import database class Currency: - def __init__(self, user_id): - self.user_id = user_id - self.balance = Currency.fetch_or_create_balance(self.user_id) + def __init__(self, user_id: int) -> None: + self.user_id: int = user_id + self.balance: int = Currency.fetch_or_create_balance(self.user_id) - def add_balance(self, amount): + def add_balance(self, amount: int) -> None: self.balance += abs(amount) - def take_balance(self, amount): + def take_balance(self, amount: int) -> None: self.balance -= abs(amount) + self.balance = max(self.balance, 0) - if self.balance < 0: - self.balance = 0 - - def push(self): - query = """ + def push(self) -> None: + query: str = """ UPDATE currency SET balance = %s WHERE user_id = %s @@ -27,15 +25,15 @@ class Currency: database.execute_query(query, (round(self.balance), self.user_id)) @staticmethod - def fetch_or_create_balance(user_id): - query = """ + def fetch_or_create_balance(user_id: int) -> int: + query: str = """ SELECT balance FROM currency WHERE user_id = %s """ try: - balance = database.select_query_one(query, (user_id,)) + balance: int | None = database.select_query_one(query, (user_id,)) except (IndexError, TypeError): balance = None @@ -53,31 +51,27 @@ class Currency: return balance @staticmethod - def load_leaderboard(): - query = "SELECT user_id, balance FROM currency ORDER BY balance DESC" - data = database.select_query(query) + def load_leaderboard() -> list[tuple[int, int, int]]: + query: str = "SELECT user_id, balance FROM currency ORDER BY balance DESC" + data: list[tuple[int, int]] = database.select_query(query) return [(row[0], row[1], rank) for rank, row in enumerate(data, start=1)] @staticmethod - def format(num): + def format(num: int) -> str: locale.setlocale(locale.LC_ALL, "en_US.UTF-8") return locale.format_string("%d", num, grouping=True) @staticmethod - def format_human(num): - num = float("{:.3g}".format(num)) - magnitude = 0 - while abs(num) >= 1000: + def format_human(num: int) -> str: + num_float: float = float(f"{num:.3g}") + magnitude: int = 0 + while abs(num_float) >= 1000: magnitude += 1 - num /= 1000.0 + num_float /= 1000.0 - return "{}{}".format( - "{:f}".format(num).rstrip("0").rstrip("."), - ["", "K", "M", "B", "T", "Q", "Qi", "Sx", "Sp", "Oc", "No", "Dc"][ - magnitude - ], - ) + suffixes: list[str] = ["", "K", "M", "B", "T", "Q", "Qi", "Sx", "Sp", "Oc", "No", "Dc"] + return f'{f"{num_float:f}".rstrip("0").rstrip(".")}{suffixes[magnitude]}' # A Thousand = K # Million = M diff --git a/services/daily_service.py b/services/daily_service.py index 9e5e119..e617470 100644 --- a/services/daily_service.py +++ b/services/daily_service.py @@ -1,10 +1,8 @@ from datetime import datetime, timedelta -from typing import List, Optional, Tuple - -import pytz +from zoneinfo import ZoneInfo from db import database -from lib.constants import CONST +from lib.const import CONST from services.currency_service import Currency @@ -12,7 +10,7 @@ class Dailies: def __init__(self, user_id: int) -> None: self.user_id: int = user_id self.amount: int = 0 - self.tz = pytz.timezone("US/Eastern") + self.tz = ZoneInfo("US/Eastern") self.time_now: datetime = datetime.now(tz=self.tz) self.reset_time: datetime = self.time_now.replace( hour=7, @@ -21,7 +19,7 @@ class Dailies: microsecond=0, ) - data: Tuple[Optional[str], int] = Dailies.get_data(user_id) + data: tuple[str | None, int] = Dailies.get_data(user_id) if data[0] is not None: self.claimed_at: datetime = datetime.fromisoformat(data[0]) @@ -38,7 +36,7 @@ class Dailies: INSERT INTO dailies (user_id, amount, claimed_at, streak) VALUES (%s, %s, %s, %s) """ - values: Tuple[int, int, str, int] = ( + values: tuple[int, int, str, int] = ( self.user_id, self.amount, self.claimed_at.isoformat(), @@ -51,9 +49,6 @@ class Dailies: cash.push() def can_be_claimed(self) -> bool: - if self.claimed_at is None: - return True - if self.time_now < self.reset_time: self.reset_time -= timedelta(days=1) @@ -68,21 +63,14 @@ class Dailies: :return: """ - check_1: bool = ( - self.claimed_at.date() == (self.time_now - timedelta(days=1)).date() - ) - check_2: bool = ( - self.claimed_at.date() == (self.time_now - timedelta(days=2)).date() - ) - check_3: bool = ( - self.claimed_at.date() == self.time_now.date() - and self.claimed_at < self.reset_time - ) + check_1: bool = self.claimed_at.date() == (self.time_now - timedelta(days=1)).date() + check_2: bool = self.claimed_at.date() == (self.time_now - timedelta(days=2)).date() + check_3: bool = self.claimed_at.date() == self.time_now.date() and self.claimed_at < self.reset_time return check_1 or check_2 or check_3 @staticmethod - def get_data(user_id: int) -> Tuple[Optional[str], int]: + def get_data(user_id: int) -> tuple[str | None, int]: query: str = """ SELECT claimed_at, streak FROM dailies @@ -101,7 +89,7 @@ class Dailies: return claimed_at, streak @staticmethod - def load_leaderboard() -> List[Tuple[int, int, str, int]]: + def load_leaderboard() -> list[tuple[int, int, str, int]]: query: str = """ SELECT user_id, MAX(streak), claimed_at FROM dailies @@ -109,9 +97,9 @@ class Dailies: ORDER BY MAX(streak) DESC; """ - data: List[Tuple[int, int, str]] = database.select_query(query) + data: list[tuple[int, int, str]] = database.select_query(query) - leaderboard: List[Tuple[int, int, str, int]] = [ + leaderboard: list[tuple[int, int, str, int]] = [ (row[0], row[1], row[2], rank) for rank, row in enumerate(data, start=1) ] return leaderboard diff --git a/services/help_service.py b/services/help_service.py deleted file mode 100644 index 757c231..0000000 --- a/services/help_service.py +++ /dev/null @@ -1,128 +0,0 @@ -import discord -from discord.ext import commands - -from lib.constants import CONST -from lib.embed_builder import EmbedBuilder -from lib.exceptions.LumiExceptions import LumiException - - -class LumiHelp(commands.HelpCommand): - def __init__(self, **options): - super().__init__(**options) - self.verify_checks = True - self.command_attrs = { - "aliases": ["h"], - "help": "Show a list of commands, or information about a specific command when an argument is passed.", - "name": "help", - "hidden": True, - } - - def get_command_qualified_name(self, command): - return f"`{self.context.clean_prefix}{command.qualified_name}`" - - async def send_bot_help(self, mapping): - embed = EmbedBuilder.create_success_embed( - ctx=self.context, - author_text="Help Command", - show_name=False, - ) - - for cog, lumi_commands in mapping.items(): - filtered = await self.filter_commands(lumi_commands, sort=True) - - if command_signatures := [ - self.get_command_qualified_name(c) for c in filtered - ]: - # Remove duplicates using set() and convert back to a list - unique_command_signatures = list(set(command_signatures)) - cog_name = getattr(cog, "qualified_name", "Help") - embed.add_field( - name=cog_name, - value=", ".join(sorted(unique_command_signatures)), - inline=False, - ) - - channel = self.get_destination() - await channel.send(embed=embed) - - async def send_command_help(self, command): - embed = EmbedBuilder.create_success_embed( - ctx=self.context, - author_text=f"{self.context.clean_prefix}{command.qualified_name}", - description=command.help, - show_name=False, - ) - - usage_value = ( - f"`{self.context.clean_prefix}{command.qualified_name} {command.signature}`" - ) - for alias in command.aliases: - usage_value += f"\n`{self.context.clean_prefix}{alias} {command.signature}`" - embed.add_field(name="Usage", value=usage_value, inline=False) - - channel = self.get_destination() - await channel.send(embed=embed) - - async def send_error_message(self, error): - raise LumiException(error) - - async def send_group_help(self, group): - raise LumiException( - CONST.STRINGS["error_command_not_found"].format(group.qualified_name), - ) - - async def send_cog_help(self, cog): - raise LumiException( - CONST.STRINGS["error_command_not_found"].format(cog.qualified_name), - ) - - async def command_callback(self, ctx, *, command=None): - await self.prepare_help_command(ctx, command) - bot = ctx.bot - - if command is None: - mapping = self.get_bot_mapping() - return await self.send_bot_help(mapping) - - # Check if it's a cog - cog = bot.get_cog(command) - if cog is not None: - return await self.send_cog_help(cog) - - maybe_coro = discord.utils.maybe_coroutine # type: ignore - - # If it's not a cog then it's a command. - # Since we want to have detailed errors when someone - # passes an invalid subcommand, we need to walk through - # the command group chain ourselves. - keys = command.split(" ") - - cmd = bot.all_commands.get(keys[0].removeprefix(self.context.prefix)) - if cmd is None: - string = await maybe_coro( - self.command_not_found, - self.remove_mentions(keys[0]), - ) - return await self.send_error_message(string) - - for key in keys[1:]: - try: - found = cmd.all_commands.get(key) - except AttributeError: - string = await maybe_coro( - self.subcommand_not_found, - cmd, - self.remove_mentions(key), - ) - return await self.send_error_message(string) - else: - if found is None: - string = await maybe_coro( - self.subcommand_not_found, - cmd, - self.remove_mentions(key), - ) - return await self.send_error_message(string) - cmd = found - - return await self.send_command_help(cmd) diff --git a/services/inventory_service.py b/services/inventory_service.py deleted file mode 100644 index c842486..0000000 --- a/services/inventory_service.py +++ /dev/null @@ -1,77 +0,0 @@ -from loguru import logger - -from db import database -from services import item_service - - -class Inventory: - def __init__(self, user_id): - self.user_id = user_id - - def add_item(self, item: item_service.Item, quantity=1): - """ - Adds an item with a specific count (default 1) to the database, if there are - no records of this user having that item yet, it will just add a record with quantity=quantity. - :param item: - :param quantity: - :return: - """ - - query = """ - INSERT INTO inventory (user_id, item_id, quantity) - VALUES (%s, %s, %s) - ON DUPLICATE KEY UPDATE quantity = quantity + %s; - """ - - database.execute_query( - query, - (self.user_id, item.id, abs(quantity), abs(quantity)), - ) - - def take_item(self, item: item_service.Item, quantity=1): - query = """ - INSERT INTO inventory (user_id, item_id, quantity) - VALUES (%s, %s, 0) - ON DUPLICATE KEY UPDATE quantity = CASE - WHEN quantity - %s < 0 THEN 0 - ELSE quantity - %s - END; - """ - - database.execute_query( - query, - (self.user_id, item.id, self.user_id, item.id, abs(quantity)), - ) - - def get_inventory(self): - query = "SELECT item_id, quantity FROM inventory WHERE user_id = %s AND quantity > 0" - results = database.select_query(query, (self.user_id,)) - - items_dict = {} - for row in results: - item_id, quantity = row - item = item_service.Item(item_id) - items_dict[item] = quantity - - return items_dict - - def get_item_quantity(self, item: item_service.Item): - query = "SELECT COALESCE(quantity, 0) FROM inventory WHERE user_id = %s AND item_id = %s" - return database.select_query_one(query, (self.user_id, item.id)) - - def get_sell_data(self): - query = """ - SELECT item.display_name - FROM inventory - JOIN ShopItem ON inventory.item_id = ShopItem.item_id - JOIN item ON inventory.item_id = item.id - WHERE inventory.user_id = %s AND inventory.quantity > 0 AND ShopItem.worth > 0 - """ - - try: - results = database.select_query(query, (self.user_id,)) - return [item[0] for item in results] - - except Exception as e: - logger.error(e) - return [] diff --git a/services/item_service.py b/services/item_service.py deleted file mode 100644 index 5afb20a..0000000 --- a/services/item_service.py +++ /dev/null @@ -1,69 +0,0 @@ -import sqlite3 - -from loguru import logger - -from db import database - - -class Item: - def __init__(self, item_id): - self.id = item_id - - data = self.get_item_data() - - self.name = data[0] - self.display_name = data[1] - self.description = data[2] - self.image_url = data[3] - self.emote_id = data[4] - self.quote = data[5] - self.type = data[6] - - def get_item_data(self): - query = """ - SELECT name, display_name, description, image_url, emote_id, quote, type - FROM item - WHERE id = %s - """ - - return database.select_query(query, (self.id,))[0] - - def get_quantity(self, author_id): - query = """ - SELECT COALESCE((SELECT quantity FROM inventory WHERE user_id = %s AND item_id = %s), 0) AS quantity - """ - - return database.select_query_one(query, (author_id, self.id)) - - def get_item_worth(self): - query = """ - SELECT worth - FROM ShopItem - WHERE item_id = %s - """ - - return database.select_query_one(query, (self.id,)) - - @staticmethod - def get_all_item_names(): - query = "SELECT display_name FROM item" - - try: - items = database.select_query(query) - return [item[0] for item in items] - - except sqlite3.Error: - logger.error(sqlite3.Error) - return [] - - @staticmethod - def get_item_by_display_name(display_name): - query = "SELECT id FROM item WHERE display_name = %s" - item_id = database.select_query_one(query, (display_name,)) - return Item(item_id) - - @staticmethod - def get_item_by_name(name): - query = "SELECT id FROM item WHERE name = %s" - item_id = database.select_query_one(query, (name,)) - return Item(item_id) diff --git a/services/moderation/modlog_service.py b/services/modlog_service.py similarity index 90% rename from services/moderation/modlog_service.py rename to services/modlog_service.py index 558efd9..00e956f 100644 --- a/services/moderation/modlog_service.py +++ b/services/modlog_service.py @@ -1,5 +1,3 @@ -from typing import Optional - from db.database import execute_query, select_query_one @@ -23,7 +21,7 @@ class ModLogService: """ execute_query(query, (guild_id,)) - def fetch_modlog_channel_id(self, guild_id: int) -> Optional[int]: + def fetch_modlog_channel_id(self, guild_id: int) -> int | None: query: str = """ SELECT channel_id FROM mod_log WHERE guild_id = %s AND is_enabled = TRUE diff --git a/services/reactions_service.py b/services/reactions_service.py index 7f672ec..0858a01 100644 --- a/services/reactions_service.py +++ b/services/reactions_service.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime +from typing import Any from db import database @@ -12,7 +12,7 @@ class CustomReactionsService: self, guild_id: int, message_content: str, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: message_content = message_content.lower() query = """ SELECT * FROM custom_reactions @@ -45,7 +45,7 @@ class CustomReactionsService: } return None - async def find_id(self, reaction_id: int) -> Optional[Dict[str, Any]]: + async def find_id(self, reaction_id: int) -> dict[str, Any] | None: query = """ SELECT * FROM custom_reactions WHERE id = %s @@ -70,7 +70,7 @@ class CustomReactionsService: } return None - async def find_all_by_guild(self, guild_id: int) -> List[Dict[str, Any]]: + async def find_all_by_guild(self, guild_id: int) -> list[dict[str, Any]]: query = """ SELECT * FROM custom_reactions WHERE guild_id = %s @@ -100,8 +100,8 @@ class CustomReactionsService: guild_id: int, creator_id: int, trigger_text: str, - response: Optional[str] = None, - emoji_id: Optional[int] = None, + response: str | None = None, + emoji_id: int | None = None, is_emoji: bool = False, is_full_match: bool = False, is_global: bool = True, @@ -132,11 +132,11 @@ class CustomReactionsService: async def edit_custom_reaction( self, reaction_id: int, - new_response: Optional[str] = None, - new_emoji_id: Optional[int] = None, - is_emoji: Optional[bool] = None, - is_full_match: Optional[bool] = None, - is_global: Optional[bool] = None, + new_response: str | None = None, + new_emoji_id: int | None = None, + is_emoji: bool | None = None, + is_full_match: bool | None = None, + is_global: bool | None = None, ) -> bool: query = """ UPDATE custom_reactions @@ -156,7 +156,7 @@ class CustomReactionsService: is_emoji, is_full_match, is_global, - datetime.now(timezone.utc), + datetime.now(UTC), reaction_id, ), ) @@ -176,7 +176,7 @@ class CustomReactionsService: WHERE guild_id = %s """ count = database.select_query_one(query, (guild_id,)) - return count if count else 0 + return count or 0 async def increment_reaction_usage(self, reaction_id: int) -> bool: query = """ diff --git a/services/stats_service.py b/services/stats_service.py index 7906fae..e12f139 100644 --- a/services/stats_service.py +++ b/services/stats_service.py @@ -4,21 +4,29 @@ from db import database class BlackJackStats: - def __init__(self, user_id, is_won, bet, payout, hand_player, hand_dealer): - self.user_id = user_id - self.is_won = is_won - self.bet = bet - self.payout = payout - self.hand_player = json.dumps(hand_player) - self.hand_dealer = json.dumps(hand_dealer) + def __init__( + self, + user_id: int, + is_won: bool, + bet: int, + payout: int, + hand_player: list[str], + hand_dealer: list[str], + ): + self.user_id: int = user_id + self.is_won: bool = is_won + self.bet: int = bet + self.payout: int = payout + self.hand_player: str = json.dumps(hand_player) + self.hand_dealer: str = json.dumps(hand_dealer) - def push(self): - query = """ + def push(self) -> None: + query: str = """ INSERT INTO blackjack (user_id, is_won, bet, payout, hand_player, hand_dealer) VALUES (%s, %s, %s, %s, %s, %s) """ - values = ( + values: tuple[int, bool, int, int, str, str] = ( self.user_id, self.is_won, self.bet, @@ -30,8 +38,8 @@ class BlackJackStats: database.execute_query(query, values) @staticmethod - def get_user_stats(user_id): - query = """ + def get_user_stats(user_id: int) -> dict[str, int]: + query: str = """ SELECT COUNT(*) AS amount_of_games, SUM(bet) AS total_bet, @@ -41,13 +49,14 @@ class BlackJackStats: FROM blackjack WHERE user_id = %s; """ + result: tuple[int, int, int, int, int] = database.select_query(query, (user_id,))[0] ( amount_of_games, total_bet, total_payout, winning_amount, losing_amount, - ) = database.select_query(query, (user_id,))[0] + ) = result return { "amount_of_games": amount_of_games, @@ -58,13 +67,14 @@ class BlackJackStats: } @staticmethod - def get_total_rows_count(): - query = """ + def get_total_rows_count() -> int: + query: str = """ SELECT SUM(TABLE_ROWS) FROM INFORMATION_SCHEMA.TABLES """ - return database.select_query_one(query) + result = database.select_query_one(query) + return int(result) if result is not None else 0 class SlotsStats: @@ -72,24 +82,24 @@ class SlotsStats: Handles statistics for the /slots command """ - def __init__(self, user_id, is_won, bet, payout, spin_type, icons): - self.user_id = user_id - self.is_won = is_won - self.bet = bet - self.payout = payout - self.spin_type = spin_type - self.icons = json.dumps(icons) + def __init__(self, user_id: int, is_won: bool, bet: int, payout: int, spin_type: str, icons: list[str]): + self.user_id: int = user_id + self.is_won: bool = is_won + self.bet: int = bet + self.payout: int = payout + self.spin_type: str = spin_type + self.icons: str = json.dumps(icons) - def push(self): + def push(self) -> None: """ Insert the services from any given slots game into the database """ - query = """ + query: str = """ INSERT INTO slots (user_id, is_won, bet, payout, spin_type, icons) VALUES (%s, %s, %s, %s, %s, %s) """ - values = ( + values: tuple[int, bool, int, int, str, str] = ( self.user_id, self.is_won, self.bet, @@ -101,11 +111,11 @@ class SlotsStats: database.execute_query(query, values) @staticmethod - def get_user_stats(user_id): + def get_user_stats(user_id: int) -> dict[str, int]: """ Retrieve the Slots stats for a given user from the database. """ - query = """ + query: str = """ SELECT COUNT(*) AS amount_of_games, SUM(bet) AS total_bet, @@ -118,6 +128,7 @@ class SlotsStats: WHERE user_id = %s """ + result: tuple[int, int, int, int, int, int, int] = database.select_query(query, (user_id,))[0] ( amount_of_games, total_bet, @@ -126,7 +137,7 @@ class SlotsStats: games_won_three_of_a_kind, games_won_three_diamonds, games_won_jackpot, - ) = database.select_query(query, (user_id,))[0] + ) = result return { "amount_of_games": amount_of_games, diff --git a/services/xp_service.py b/services/xp_service.py index 8cf7b31..14aa7c0 100644 --- a/services/xp_service.py +++ b/services/xp_service.py @@ -1,10 +1,10 @@ import time -from typing import Callable, Dict, List, Optional, Tuple +from collections.abc import Callable from discord.ext import commands from db import database -from lib.constants import CONST +from lib.const import CONST class XpService: @@ -24,7 +24,7 @@ class XpService: self.guild_id: int = guild_id self.xp: int = 0 self.level: int = 0 - self.cooldown_time: Optional[float] = None + self.cooldown_time: float | None = None self.xp_gain: int = CONST.XP_GAIN_PER_MESSAGE self.new_cooldown: int = CONST.XP_GAIN_COOLDOWN @@ -70,7 +70,7 @@ class XpService: self.level = user_level self.cooldown_time = cooldown - def calculate_rank(self) -> Optional[int]: + def calculate_rank(self) -> int | None: """ Determines the rank of a user in the guild based on their XP and level. @@ -83,12 +83,12 @@ class XpService: WHERE guild_id = %s ORDER BY user_level DESC, user_xp DESC """ - data: List[Tuple[int, int, int]] = database.select_query( + data: list[tuple[int, int, int]] = database.select_query( query, (self.guild_id,), ) - leaderboard: List[Tuple[int, int, int, int]] = [ + leaderboard: list[tuple[int, int, int, int]] = [ (row[0], row[1], row[2], rank) for rank, row in enumerate(data, start=1) ] return next( @@ -97,7 +97,7 @@ class XpService: ) @staticmethod - def load_leaderboard(guild_id: int) -> List[Tuple[int, int, int, int]]: + def load_leaderboard(guild_id: int) -> list[tuple[int, int, int, int]]: """ Retrieves the guild's XP leaderboard. @@ -113,9 +113,9 @@ class XpService: WHERE guild_id = %s ORDER BY user_level DESC, user_xp DESC """ - data: List[Tuple[int, int, int]] = database.select_query(query, (guild_id,)) + data: list[tuple[int, int, int]] = database.select_query(query, (guild_id,)) - leaderboard: List[Tuple[int, int, int, int]] = [] + leaderboard: list[tuple[int, int, int, int]] = [] for row in data: row_user_id: int = row[0] user_xp: int = row[1] @@ -164,7 +164,7 @@ class XpService: Returns: int: The amount of XP needed for the next level. """ - formula_mapping: Dict[Tuple[int, int], Callable[[int], int]] = { + formula_mapping: dict[tuple[int, int], Callable[[int], int]] = { (10, 19): lambda level: 12 * level + 28, (20, 29): lambda level: 15 * level + 29, (30, 39): lambda level: 18 * level + 30, @@ -182,11 +182,7 @@ class XpService: for level_range, formula in formula_mapping.items() if level_range[0] <= current_level <= level_range[1] ), - ( - 10 * current_level + 27 - if current_level < 10 - else 42 * current_level + 37 - ), + (10 * current_level + 27 if current_level < 10 else 42 * current_level + 37), ) @@ -203,9 +199,9 @@ class XpRewardService: guild_id (int): The ID of the guild. """ self.guild_id: int = guild_id - self.rewards: Dict[int, Tuple[int, bool]] = self._fetch_rewards() + self.rewards: dict[int, tuple[int, bool]] = self._fetch_rewards() - def _fetch_rewards(self) -> Dict[int, Tuple[int, bool]]: + def _fetch_rewards(self) -> dict[int, tuple[int, bool]]: """ Retrieves the XP rewards for the guild from the database. @@ -218,7 +214,7 @@ class XpRewardService: WHERE guild_id = %s ORDER BY level DESC """ - data: List[Tuple[int, int, bool]] = database.select_query( + data: list[tuple[int, int, bool]] = database.select_query( query, (self.guild_id,), ) @@ -237,7 +233,8 @@ class XpRewardService: commands.BadArgument: If the server has more than 25 XP rewards. """ if len(self.rewards) >= 25: - raise commands.BadArgument("A server can't have more than 25 XP rewards.") + msg = "A server can't have more than 25 XP rewards." + raise commands.BadArgument(msg) query: str = """ INSERT INTO level_rewards (guild_id, level, role_id, persistent) @@ -264,7 +261,7 @@ class XpRewardService: database.execute_query(query, (self.guild_id, level)) self.rewards.pop(level, None) - def get_role(self, level: int) -> Optional[int]: + def get_role(self, level: int) -> int | None: """ Retrieves the role ID for a given level. @@ -276,7 +273,7 @@ class XpRewardService: """ return self.rewards.get(level, (None,))[0] - def should_replace_previous_reward(self, level: int) -> Tuple[Optional[int], bool]: + def should_replace_previous_reward(self, level: int) -> tuple[int | None, bool]: """ Checks if the previous reward should be replaced based on the given level. diff --git a/settings/settings.yaml b/settings.yaml similarity index 73% rename from settings/settings.yaml rename to settings.yaml index 250b4e6..949f4b8 100644 --- a/settings/settings.yaml +++ b/settings.yaml @@ -1,16 +1,33 @@ ---- -info: - title: Luminara - author: wlinator - license: GNU General Public License v3.0 - version: "2.9.0" # "Settings & Customizability" update - repository_url: https://git.wlinator.org/Luminara/Lumi +art: + fetch_url: https://git.wlinator.org/Luminara/Art/raw/branch/main/ + logo: + opaque: lumi_logo.png + transparent: lumi_logo_transparent.png + icons: + boost: lumi_boost.png + check: lumi_check.png + cross: lumi_cross.png + exclaim: lumi_exclaim.png + info: lumi_info.png?_=2 + hammer: lumi_hammer.png + money_bag: lumi_money_bag.png + money_coins: lumi_money_coins.png + question: lumi_question.png + streak: lumi_streak.png + streak_bronze: lumi_streak_bronze.png\ + streak_gold: lumi_streak_gold.png + streak_silver: lumi_streak_silver.png + warning: lumi_warning.png + juicybblue: + flowers: https://i.imgur.com/79XfsbS.png + teapot: https://i.imgur.com/wFsgSnr.png + muffin: https://i.imgur.com/hSauh7K.png + other: + cloud: https://i.imgur.com/rc68c43.png + trophy: https://i.imgur.com/dvIIr2G.png -images: - allowed_image_extensions: - - .jpg - - .png - birthday_gif_url: https://media1.tenor.com/m/NXvU9jbBUGMAAAAC/fireworks.gif +cogs: + ignore: # add cogs to ignore here colors: color_default: 0xFF8C00 @@ -28,33 +45,6 @@ economy: three_diamonds: 6 jackpot: 15 -art: - fetch_url: https://git.wlinator.org/Luminara/Art/raw/branch/main/ - logo: - opaque: lumi_logo.png - transparent: lumi_logo_transparent.png - icons: - boost: lumi_boost.png - check: lumi_check.png - cross: lumi_cross.png - exclaim: lumi_exclaim.png - hammer: lumi_hammer.png - money_bag: lumi_money_bag.png - money_coins: lumi_money_coins.png - question: lumi_question.png - streak: lumi_streak.png - streak_bronze: lumi_streak_bronze.png - streak_gold: lumi_streak_gold.png - streak_silver: lumi_streak_silver.png - warning: lumi_warning.png - juicybblue: - flowers: https://i.imgur.com/79XfsbS.png - teapot: https://i.imgur.com/wFsgSnr.png - muffin: https://i.imgur.com/hSauh7K.png - other: - cloud: https://i.imgur.com/rc68c43.png - trophy: https://i.imgur.com/dvIIr2G.png - emotes: guild_id: 1038051105642401812 emote_ids: @@ -87,17 +77,36 @@ emotes: Blank: 1119287267001905283 lost: 1119288454212243607 +images: + allowed_image_extensions: + - .jpg + - .png + - .jpeg + birthday_gif_url: https://media1.tenor.com/m/NXvU9jbBUGMAAAAC/fireworks.gif + +info: + title: Luminara + author: wlinator + license: GNU General Public License v3.0 + version: "3.0.0-alpha" + repository_url: https://git.wlinator.org/Luminara/Lumi + invite_url: https://discord.com/oauth2/authorize?client_id=1038050427272429588&permissions=8&scope=bot + introductions: - intro_guild_id: 719227135151046700 - intro_channel_id: 973619250507972600 + intro_guild_id: 1219635811977269371 + intro_channel_id: 1219637688328523806 intro_question_mapping: - Nickname: How would you like to be identified in the server? - Age: How old are you? - Region: Where do you live? - Languages: Which languages do you speak? - Pronouns: What are your preferred pronouns? - Sexuality: What's your sexuality? - Relationship status: What's your current relationship status? - Likes & interests: Likes & interests - Dislikes: Dislikes + Nickname: how would you like to be identified in the server? (nickname) + Age: how old are you? + Region: where do you live? + Languages: which languages do you speak? + Pronouns: what are your preferred pronouns? + Sexuality: what's your sexuality? + Relationship status: what's your current relationship status? + Likes & interests: likes & interests + Dislikes: dislikes EXTRAS: "EXTRAS: job status, zodiac sign, hobbies, etc. Tell us about yourself!" + +logs: + level: DEBUG + format: "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" diff --git a/settings/responses/bdays.en-US.json b/settings/responses/bdays.en-US.json deleted file mode 100644 index b0626a9..0000000 --- a/settings/responses/bdays.en-US.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "months": [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December" - ], - "birthday_messages": [ - "🎂 Happy Birthday, **{0}**! 🎉 Wishing you a day filled with joy and laughter.", - "🎈 It's party time! Happy Birthday, **{0}**! 🎉", - "🎉 Another year older, another year wiser! Happy Birthday, **{0}**! 🎂", - "🌟 Today's the day you shine brighter than ever! Happy Birthday, **{0}**! 🌟", - "🎁 Special day alert! It's **{0}**'s birthday! 🎁", - "🎊 Hip, hip, hooray! It's **{0}**'s birthday today! 🎊", - "🎂 Cake and confetti time! Happy Birthday, **{0}**! 🎉", - "🌈 Sending you a rainbow of happiness on your birthday, **{0}**! 🎈", - "🎉 Let's raise a toast to **{0}** on their birthday! Cheers to another fantastic year! 🥂", - "🎈 Birthdays are like sprinkles on the cupcake of life! Happy Birthday, **{0}**! 🧁", - "🎁 Gift-wrapped wishes for a wonderful birthday and an amazing year ahead, **{0}**! 🎁", - "🎊 Time to blow out the candles and make a wish! Happy Birthday, **{0}**! 🎂", - "🌟 It's your day to sparkle and shine, **{0}**! Happy Birthday! ✨", - "🎈 May your birthday be as fabulous as you are, **{0}**! 🎉", - "🎉 Here's to a year filled with success, happiness, and endless opportunities for **{0}**! Happy Birthday! 🥳", - "🎁 Wishing **{0}** all the best on their special day! Happy Birthday! 🎁", - "🎊 Another year of unforgettable memories begins today for **{0}**! Happy Birthday! 🎊", - "🌟 Your birthday is the perfect excuse to pamper yourself, **{0}**! Enjoy your special day! 🎈", - "🎂 Age is just a number, and you're looking more fabulous with each passing year, **{0}**! Happy Birthday! 💕", - "🎉 Today, we celebrate the amazing person you are, **{0}**! Happy Birthday! 🎂", - "🎈 Life's journey gets even more exciting as **{0}** celebrates another year of it! Happy Birthday! 🎉", - "🌟 Happy Birthday to someone who makes every day brighter with their presence, **{0}**! 🌞", - "🎁 May this birthday be the beginning of the most extraordinary year yet for **{0}**! 🚀", - "🎊 Birthdays are nature's way of telling us to eat more cake! Enjoy your special treat, **{0}**! 🍰", - "🎉 Time to pop the confetti and make some fabulous birthday memories, **{0}**! 🎂", - "🎈 Today, the world received a gift in the form of **{0}**! Happy Birthday! 🎉", - "🌟 Wishing **{0}** health, happiness, and all the things they desire on their birthday! 🎁", - "🎂 Cheers to **{0}** on another year of being amazing! Happy Birthday! 🎉", - "🎉 It's **{0}**'s big day, so let loose and enjoy every moment! Happy Birthday! 🎊", - "🎈 Sending virtual hugs and lots of love to **{0}** on their special day! 🤗❤️", - "🌟 On your birthday, the world becomes a better place because of your presence, **{0}**! 🎉", - "🎁 As you blow out the candles, know that your wishes are heard and your dreams matter. Happy Birthday, **{0}**! 🌠", - "🎊 Here's to a birthday filled with laughter, love, and all the things that make you happy, **{0}**! Cheers! 🥂", - "🎂 May your birthday be filled with delightful surprises and sweet moments, **{0}**! Enjoy your special day! 🎈", - "🎉 It's time for the world to celebrate the incredible person that is **{0}**! Happy Birthday! 🎊", - "🎈 Another year, another chapter in the adventure of life for **{0}**! May this year be full of excitement and joy! 🌟", - "🌟 May this birthday mark the beginning of extraordinary achievements and unforgettable memories for **{0}**! 🎁", - "🎂 Here's to a birthday filled with love, happiness, and all the good things you deserve, **{0}**! 🎉", - "🎉 Sending virtual confetti and a big smile to **{0}** on their birthday! Let's celebrate! 🎈", - "🎈 Time to indulge in cake and celebrate the wonderful human that is **{0}**! Happy Birthday! 🎂", - "🎊 Today, we honor the amazing journey of **{0}**'s life! Happy Birthday! 🌟", - "🌟 Birthdays are a time for reflection, growth, and gratitude. Wishing **{0}** a wonderful birthday and year ahead! 🎁", - "🎁 Another trip around the sun calls for a big celebration! Happy Birthday, **{0}**! 🎉", - "🎂 As you blow out the candles, know that you are loved and cherished, **{0}**! Happy Birthday! 🎈", - "🎉 It's a new age and a new opportunity to shine, **{0}**! May this year be your best yet! 🌟", - "🎈 On your special day, may you be surrounded by love, happiness, and everything that brings you joy, **{0}**! 🎂", - "🌟 Today, we celebrate **{0}** and the unique light they bring into the world! Happy Birthday! 🎉", - "🎁 Wishing a fantastic birthday to the one and only **{0}**! May this day be filled with laughter and love! 🎈", - "🎊 May your birthday be as extraordinary as the person you are, **{0}**! Cheers to you! 🥳", - "🎂 You're not just a year older; you're a year more incredible! Happy Birthday, **{0}**! 🌟", - "🎉 It's time to embrace the joy, love, and happiness that come with birthdays! Enjoy every moment, **{0}**! 🎈", - "🎈 Another year, another chance to create beautiful memories. Happy Birthday, **{0}**! 🎂", - "🌟 Your birthday is a reminder of how much you mean to all of us! Wishing you the best day ever, **{0}**! 🎁", - "🎁 Sending warm birthday wishes and virtual hugs to **{0}** on their special day! 🤗❤️", - "🎉 Today, we celebrate the unique and wonderful person that is **{0}**! Happy Birthday! 🎂", - "🎈 Here's to a birthday filled with laughter, love, and all the things that make you smile, **{0}**! 🌟", - "🎊 It's a day to be spoiled and celebrated, **{0}**! Wishing you the happiest of birthdays! 🎁", - "🎂 As you turn another year older, know that you are loved and cherished, **{0}**! Happy Birthday! 🎉" - ] -} \ No newline at end of file diff --git a/stubs/reactionmenu/__init__.pyi b/stubs/reactionmenu/__init__.pyi new file mode 100644 index 0000000..f7a090f --- /dev/null +++ b/stubs/reactionmenu/__init__.pyi @@ -0,0 +1,21 @@ +""" +This type stub file was generated by pyright. +""" + +from .buttons import ReactionButton, ViewButton +from .core import ReactionMenu +from .views_menu import ViewMenu, ViewSelect +from .abc import Page + +""" +reactionmenu • discord pagination +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A library to create a discord.py 2.0+ paginator. Supports pagination with buttons, reactions, and category selection using selects. + +:copyright: (c) 2021-present @defxult +:license: MIT + +""" +__source__ = ... +__all__ = ('ReactionMenu', 'ReactionButton', 'ViewMenu', 'ViewButton', 'ViewSelect', 'Page') diff --git a/stubs/reactionmenu/abc.pyi b/stubs/reactionmenu/abc.pyi new file mode 100644 index 0000000..69bdd1a --- /dev/null +++ b/stubs/reactionmenu/abc.pyi @@ -0,0 +1,816 @@ +""" +This type stub file was generated by pyright. +""" + +import abc +import discord +from typing import Any, Callable, ClassVar, Final, Generic, List, Literal, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, TypeVar, Union, overload +from datetime import datetime +from typing_extensions import Self +from collections.abc import Sequence +from enum import Enum +from discord.ext.commands import Context +from discord.utils import MISSING +from .decorators import ensure_not_primed +from .errors import * + +""" +MIT License + +Copyright (c) 2021-present @defxult + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +if TYPE_CHECKING: + ... +_DYNAMIC_EMBED_LIMIT: Final[int] = ... +_DEFAULT_STYLE: Final[str] = ... +DEFAULT_BUTTONS = ... +DEFAULT = MISSING +GB = TypeVar('GB', bound='_BaseButton') +M = TypeVar('M', bound='_BaseMenu') +class Page: + """Represents a single "page" in the pagination process + + .. added:: v3.1.0 + """ + __slots__ = ... + def __init__(self, *, content: Optional[str] = ..., embed: Optional[discord.Embed] = ..., files: Optional[List[discord.File]] = ...) -> None: + ... + + def __repr__(self) -> str: + ... + + @staticmethod + def from_embeds(embeds: Sequence[discord.Embed]) -> List[Page]: + """|static method| + + Converts a sequence of embeds into a list of :class:`Page` + """ + ... + + + +class PaginationEmojis: + """A set of basic emojis for your convenience to use for your buttons emoji + - ◀️ as `BACK_BUTTON` + - ▶️ as `NEXT_BUTTON` + - ⏪ as `FIRST_PAGE` + - ⏩ as `LAST_PAGE` + - 🔢 as `GO_TO_PAGE` + - ⏹️ as `END_SESSION` + """ + BACK_BUTTON: ClassVar[str] = ... + NEXT_BUTTON: ClassVar[str] = ... + FIRST_PAGE: ClassVar[str] = ... + LAST_PAGE: ClassVar[str] = ... + GO_TO_PAGE: ClassVar[str] = ... + END_SESSION: ClassVar[str] = ... + + +class _PageController: + def __init__(self, pages: List[Page]) -> None: + ... + + @property + def current_page(self) -> Page: + ... + + @property + def total_pages(self) -> int: + """Return the total amount of pages registered to the menu""" + ... + + def validate_index(self) -> Page: + """If the index is out of bounds, assign the appropriate values so the pagination process can continue and return the associated page""" + ... + + def skip_loop(self, action: str, amount: int) -> None: + """Using `self.index += amount` does not work because this library is used to operating on a +-1 basis. This loop + provides a simple way to still operate on the +-1 standard. + """ + ... + + def skip(self, skip: _BaseButton.Skip) -> Page: + """Return the page that the skip value was set to""" + ... + + def next(self) -> Page: + """Return the next page in the pagination process""" + ... + + def prev(self) -> Page: + """Return the previous page in the pagination process""" + ... + + def first_page(self) -> Page: + """Return the first page in the pagination process""" + ... + + def last_page(self) -> Page: + """Return the last page in the pagination process""" + ... + + + +class _MenuType(Enum): + TypeEmbed = ... + TypeEmbedDynamic = ... + TypeText = ... + + +MenuType = _MenuType +class _LimitDetails(NamedTuple): + limit: int + per: str + message: str + set_by_user: bool = ... + @classmethod + def default(cls) -> Self: + ... + + + +class _BaseButton(Generic[GB], metaclass=abc.ABCMeta): + Emojis: ClassVar[PaginationEmojis] = ... + def __init__(self, name: str, event: Optional[_BaseButton.Event], skip: _BaseButton.Skip) -> None: + ... + + @property + @abc.abstractmethod + def menu(self): + ... + + @property + def clicked_by(self) -> Set[discord.Member]: + """ + Returns + ------- + Set[:class:`discord.Member`]: The members who clicked the button + """ + ... + + @property + def total_clicks(self) -> int: + """ + Returns + ------- + :class:`int`: The amount of clicks on the button + """ + ... + + @property + def last_clicked(self) -> Optional[datetime]: + """ + Returns + ------- + Optional[:class:`datetime.datetime`]: The time in UTC for when the button was last clicked. Can be :class:`None` if the button has not been clicked + """ + ... + + class Skip: + """Initialize a skip button with the appropriate values + + Parameters + ---------- + action: :class:`str` + Whether to go forward in the pagination process ("+") or backwards ("-") + + amount: :class:`int` + The amount of pages to skip. Must be >= 1. If value is <= 0, it is implicitly set to 2 + """ + def __init__(self, action: Literal['+', '-'], amount: int) -> None: + ... + + + + class Event: + """Set a button to be disabled or removed when it has been pressed a certain amount of times. If the button is a :class:`ReactionButton`, only the "remove" event is available + + Parameters + ---------- + event: :class:`str` + The action to take. Can either be "disable" or "remove" + + value: :class:`int` + The amount set for the specified event. Must be >= 1. If value is <= 0, it is implicitly set to 1""" + _DISABLE = ... + _REMOVE = ... + def __init__(self, event_type: Literal['disable', 'remove'], value: int) -> None: + ... + + + + + +class _BaseMenu(metaclass=abc.ABCMeta): + TypeEmbed: Final[_MenuType] = ... + TypeEmbedDynamic: Final[_MenuType] = ... + TypeText: Final[_MenuType] = ... + _sessions_limit_details = ... + _active_sessions: List[Self] + def __init__(self, method: Union[Context, discord.Interaction], /, menu_type: _MenuType, **kwargs) -> None: + ... + + @abc.abstractmethod + def remove_all_buttons(self): + ... + + @abc.abstractmethod + def get_button(self): + ... + + @abc.abstractmethod + def remove_button(self): + ... + + @abc.abstractmethod + def add_button(self): + ... + + @abc.abstractmethod + def add_buttons(self): + ... + + @abc.abstractmethod + def stop(self): + ... + + @abc.abstractmethod + async def start(self): + ... + + @abc.abstractmethod + async def quick_start(cls): + ... + + @staticmethod + def separate(values: Sequence[Any]) -> Tuple[List[discord.Embed], List[str]]: + """|static method| + + Sorts all embeds and strings into a single tuple + + Parameters + ---------- + values: Sequence[`Any`] + The values to separate + + Returns + ------- + Tuple[List[:class:`discord.Embed`], List[:class:`str`]] + + Example + ------- + >>> embeds, strings = .separate([...]) + """ + ... + + @staticmethod + def all_embeds(values: Sequence[Any]) -> bool: + """|static method| + + Tests to see if all items in the sequence are of type :class:`discord.Embed` + + Parameters + ---------- + values: Sequence[`Any`] + The values to test + + Returns + ------- + :class:`bool`: Can return `False` if the sequence is empty + """ + ... + + @staticmethod + def all_strings(values: Sequence[Any]) -> bool: + """|static method| + + Tests to see if all items in the sequence are of type :class:`str` + + Parameters + ---------- + values: Sequence[`Any`] + The values to test + + Returns + ------- + :class:`bool`: Can return `False` if the sequence is empty + """ + ... + + @classmethod + def remove_limit(cls) -> None: + """|class method| + + Remove the limits currently set for menu's + """ + ... + + @classmethod + def get_all_dm_sessions(cls) -> List[Self]: + """|class method| + + Retrieve all active DM menu sessions + + Returns + ------- + A :class:`list` of active DM menu sessions that are currently running. Can be an empty list if there are no active DM sessions + """ + ... + + @classmethod + def get_all_sessions(cls) -> List[Self]: + """|class method| + + Retrieve all active menu sessions + + Returns + ------- + A :class:`list` of menu sessions that are currently running. Can be an empty list if there are no active sessions + """ + ... + + @classmethod + def get_session(cls, name: str) -> List[Self]: + """|class method| + + Get a menu instance by it's name + + Parameters + ---------- + name: :class:`str` + The name of the menu to return + + Returns + ------- + A :class:`list` of menu sessions that are currently running that match the supplied :param:`name`. Can be an empty list if there are no active sessions that matched the :param:`name` + """ + ... + + @classmethod + def get_sessions_count(cls) -> int: + """|class method| + + Returns the number of active sessions + + Returns + ------- + :class:`int`: The amount of menu sessions that are active + """ + ... + + @classmethod + def set_sessions_limit(cls, limit: int, per: Literal['channel', 'guild', 'member'] = ..., message: str = ...) -> None: + """|class method| + + Sets the amount of menu sessions that can be active at the same time per guild, channel, or member. This applies to both :class:`ReactionMenu` & :class:`ViewMenu` + + Parameters + ---------- + limit: :class:`int` + The amount of menu sessions allowed + + per: :class:`str` + How menu sessions should be limited. Options: "channel", "guild", or "member" + + message: :class:`str` + Message that will be sent informing users about the menu limit when the limit is reached. Can be :class:`None` for no message + + Raises + ------ + - `IncorrectType`: The :param:`limit` parameter was not of type :class:`int` + - `MenuException`: The value of :param:`per` was not valid or the limit was not greater than or equal to one + """ + ... + + @classmethod + async def stop_session(cls, name: str, include_all: bool = ...) -> None: + """|coro class method| + + Stop a specific menu with the supplied name + + Parameters + ---------- + name: :class:`str` + The menus name + + include_all: :class:`bool` + If set to `True`, it stops all menu sessions with the supplied :param:`name`. If `False`, stops only the most recently started menu with the supplied :param:`name` + + Raises + ------ + - `MenuException`: The session with the supplied name was not found + """ + ... + + @classmethod + async def stop_all_sessions(cls) -> None: + """|coro class method| + + Stops all menu sessions that are currently running + """ + ... + + @classmethod + def get_menu_from_message(cls, message_id: int, /) -> Optional[Self]: + """|class method| + + Return the menu object associated with the message with the given ID + + Parameters + ---------- + message_id: :class:`int` + The `discord.Message.id` from the menu message + + Returns + ------- + The menu object. Can be :class:`None` if the menu was not found in the list of active menu sessions + """ + ... + + @property + def rows(self) -> Optional[List[str]]: + """ + Returns + ------- + Optional[List[:class:`str`]]: All rows that's been added to the menu. Can return `None` if the menu has not started or the `menu_type` is not `TypeEmbedDynamic` + + .. added: v3.1.0 + """ + ... + + @property + def menu_type(self) -> str: + """ + Returns + ------- + :class:`str`: The `menu_type` you set via the constructor. This will either be `TypeEmbed`, `TypeEmbedDynamic`, or `TypeText` + + .. added:: v3.1.0 + """ + ... + + @property + def last_viewed(self) -> Optional[Page]: + """ + Returns + ------- + Optional[:class:`Page`]: The last page that was viewed in the pagination process. Can be :class:`None` if the menu has not been started + """ + ... + + @property + def owner(self) -> Union[discord.Member, discord.User]: + """ + Returns + ------- + Union[:class:`discord.Member`, :class:`discord.User`]: The owner of the menu (the person that started the menu). If the menu was started in a DM, this will return :class:`discord.User` + """ + ... + + @property + def total_pages(self) -> int: + """ + Returns + ------- + :class:`int`: The amount of pages that have been added to the menu. If the `menu_type` is :attr:`TypeEmbedDynamic`, the amount of pages is not known until AFTER the menu has started. + If attempted to retrieve the value before a dynamic menu has started, this will return a value of -1 + """ + ... + + @property + def pages(self) -> Optional[List[Page]]: + """ + Returns + ------- + Optional[List[:class:`Page`]]: The pages currently applied to the menu. Can return :class:`None` if there are no pages + + Note: If the `menu_type` is :attr:`TypeEmbedDynamic`, the pages aren't known until after the menu has started + """ + ... + + @property + def message(self) -> Optional[Union[discord.Message, discord.InteractionMessage]]: + """ + Returns + ------- + Optional[Union[:class:`discord.Message`, :class:`discord.InteractionMessage`]]: The menu's message object. Can be :class:`None` if the menu has not been started + """ + ... + + @property + def is_running(self) -> bool: + """ + Returns + ------- + :class:`bool`: `True` if the menu is currently running, `False` otherwise + """ + ... + + @property + def in_dms(self) -> bool: + """ + Returns + ------- + :class:`bool`: If the menu was started in a DM + """ + ... + + def randomize_embed_colors(self) -> None: + """Randomize the color of all the embeds that have been added to the menu + + Raises + ------ + - `MenuException`: The `menu_type` was not of `TypeEmbed` + + .. added:: v3.1.0 + """ + ... + + def set_page_director_style(self, style_id: int, separator: str = ...) -> None: + """Set how the page numbers dictating what page you are on (in the footer of an embed/regular message) are displayed + + Parameters + ---------- + style_id: :class:`int` + Varying formats of how the page director can be presented. The following ID's are available: + + - `1` = Page 1/10 + - `2` = Page 1 out of 10 + - `3` = 1 out of 10 + - `4` = 1 • 10 + - `5` = 1 » 10 + - `6` = 1 | 10 + - `7` = 1 : 10 + - `8` = 1 - 10 + - `9` = 1 / 10 + - `10` = 1 🔹 10 + - `11` = 1 🔸 10 + + separator: :class:`str` + The separator between the page director and any text you may have in the embed footer. The default separator is ":". It should be noted that whichever separator you assign, + if you wish to have spacing between the page director and the separator, you must place the space inside the string yourself as such: " :" + + Raises + ------ + - `MenuException`: The :param:`style_id` value was not valid + """ + ... + + async def wait_until_closed(self) -> None: + """|coro| + + Waits until the menu session ends using `.stop()` or when the menu times out. This should not be used inside relays + + .. added:: v3.0.1 + """ + ... + + @ensure_not_primed + def add_from_messages(self, messages: Sequence[discord.Message]) -> None: + """Add pages to the menu using the message object itself + + Parameters + ---------- + messages: Sequence[:class:`discord.Message`] + A sequence of discord message objects + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: The messages provided did not have the correct values. For example, the `menu_type` was set to `TypeEmbed`, but the messages you've provided only contains text. If the `menu_type` is `TypeEmbed`, only messages with embeds should be provided + - `IncorrectType`: All messages were not of type :class:`discord.Message` + """ + ... + + @ensure_not_primed + async def add_from_ids(self, messageable: discord.abc.Messageable, message_ids: Sequence[int]) -> None: + """|coro| + + Add pages to the menu using the IDs of messages. This only grabs embeds (if the `menu_type` is :attr:`TypeEmbed`) or the content (if the `menu_type` is :attr:`TypeText`) from the message + + Parameters + ---------- + messageable: :class:`discord.abc.Messageable` + A discord :class:`Messageable` object (:class:`discord.TextChannel`, :class:`commands.Context`, etc.) + + message_ids: Sequence[:class:`int`] + The messages to fetch + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: The message IDs provided did not have the correct values when fetched. For example, the `menu_type` was set to `TypeEmbed`, but the messages you've provided for the library to fetch only contains text. If the `menu_type` is `TypeEmbed`, only messages with embeds should be provided + - `MenuException`: An error occurred when attempting to fetch a message or not all :param:`message_ids` were of type int + """ + ... + + @ensure_not_primed + def clear_all_row_data(self) -> None: + """Delete all the data thats been added using :meth:`add_row()` + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: This method was called but the menus `menu_type` was not :attr:`TypeEmbedDynamic` + """ + ... + + @ensure_not_primed + def add_row(self, data: str) -> None: + """Add text to the embed page by rows of data + + Parameters + ---------- + data: :class:`str` + The data to add + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: This method was called but the menus `menu_type` was not :attr:`TypeEmbedDynamic` + - `MissingSetting`: The kwarg "rows_requested" (int) has not been set for the menu + """ + ... + + @ensure_not_primed + def set_main_pages(self, *embeds: discord.Embed) -> None: + """On a menu with a `menu_type` of :attr:`TypeEmbedDynamic`, set the pages you would like to show first. These embeds will be shown before the embeds that contain your data + + Parameters + ---------- + *embeds: :class:`discord.Embed` + An argument list of :class:`discord.Embed` objects + + Raises + ------ + - `MenuSettingsMismatch`: Tried to use method on a menu that was not of `menu_type` :attr:`TypeEmbedDynamic` + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuException`: The "embeds" parameter was empty. At least one value is needed + - `IncorrectType`: All values in the argument list were not of type :class:`discord.Embed` + """ + ... + + @ensure_not_primed + def set_last_pages(self, *embeds: discord.Embed) -> None: + """On a menu with a `menu_type` of :attr:`TypeEmbedDynamic`, set the pages you would like to show last. These embeds will be shown after the embeds that contain your data + + Parameters + ---------- + *embeds: :class:`discord.Embed` + An argument list of :class:`discord.Embed` objects + + Raises + ------ + - `MenuSettingsMismatch`: Tried to use method on a menu that was not of `menu_type` :attr:`TypeEmbedDynamic` + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuException`: The "embeds" parameter was empty. At least one value is needed + - `IncorrectType`: All values in the argument list were not of type :class:`discord.Embed` + """ + ... + + @ensure_not_primed + def add_page(self, embed: Optional[discord.Embed] = ..., content: Optional[str] = ..., files: Optional[List[discord.File]] = ...) -> None: + """Add a page to the menu + + Parameters + ---------- + embed: Optional[:class:`discord.Embed`] + The embed of the page + + content: Optional[:class:`str`] + The text that appears above an embed in a message + + files: Optional[Sequence[:class:`discord.File`]] + Files you'd like to attach to the page + + Raises + ------ + - `MenuException`: Attempted to add a page with no parameters + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: The page being added does not match the menus `menu_type` + + .. changes:: + v3.1.0 + Added parameter content + Added parameter embed + Added parameter files + Removed parameter "page" + """ + ... + + @overload + def add_pages(self, pages: Sequence[discord.Embed]) -> None: + ... + + @overload + def add_pages(self, pages: Sequence[str]) -> None: + ... + + @ensure_not_primed + def add_pages(self, pages: Sequence[Union[discord.Embed, str]]) -> None: + """Add multiple pages to the menu at once + + Parameters + ---------- + pages: Sequence[Union[:class:`discord.Embed`, :class:`str`]] + The pages to add. Can only be used when the menus `menu_type` is :attr:`TypeEmbed` (adding a :class:`discord.Embed`) + or :attr:`TypeText` (adding a :class:`str`) + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `MenuSettingsMismatch`: The page being added does not match the menus `menu_type` + """ + ... + + @ensure_not_primed + def remove_all_pages(self) -> None: + """Remove all pages from the menu + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + """ + ... + + @ensure_not_primed + def remove_page(self, page_number: int) -> None: + """Remove a page from the menu + + Parameters + ---------- + page_number: :class:`int` + The page to remove + + Raises + ------ + - `MenuAlreadyRunning`: Attempted to call method after the menu has already started + - `InvalidPage`: The page associated with the given page number was not valid + """ + ... + + def set_on_timeout(self, func: Callable[[M], None]) -> None: + """Set the function to be called when the menu times out + + Parameters + ---------- + func: Callable[[:type:`M`]], :class:`None`] + The function object that will be called when the menu times out. The function should contain a single positional argument + and should not return anything. The argument passed to that function is an instance of the menu. + + Raises + ------ + - `IncorrectType`: Parameter "func" was not a callable object + """ + ... + + def remove_on_timeout(self) -> None: + """Remove the timeout call to the function you have set when the menu times out""" + ... + + def set_relay(self, func: Callable[[NamedTuple], None], *, only: Optional[List[GB]] = ...) -> None: + """Set a function to be called with a given set of information when a button is pressed on the menu. The information passed is `RelayPayload`, a named tuple. + The named tuple contains the following attributes: + + - `member`: The :class:`discord.Member` object of the person who pressed the button. Could be :class:`discord.User` if the menu was started in a DM + - `button`: Depending on the menu instance, the :class:`ReactionButton` or :class:`ViewButton` object of the button that was pressed + + Parameters + ---------- + func: Callable[[:class:`NamedTuple`], :class:`None`] + The function should only contain a single positional argument. Command functions (`@bot.command()`) not supported + + only: Optional[List[:generic:`GB`]] + A list of buttons (`GB`) associated with the current menu instance. If the menu instance is :class:`ReactionMenu`, this should be a list of :class:`ReactionButton` + and vice-versa for :class:`ViewMenu` instances. If this is :class:`None`, all buttons on the menu will be relayed. If set, only button presses from those specified buttons will be relayed + + Raises + ------ + - `IncorrectType`: The :param:`func` argument provided was not callable + """ + ... + + def remove_relay(self) -> None: + """Remove the relay that's been set""" + ... + + + diff --git a/stubs/reactionmenu/buttons.pyi b/stubs/reactionmenu/buttons.pyi new file mode 100644 index 0000000..a713cbb --- /dev/null +++ b/stubs/reactionmenu/buttons.pyi @@ -0,0 +1,549 @@ +""" +This type stub file was generated by pyright. +""" + +import discord +from typing import Any, Callable, Dict, Final, Iterable, List, Literal, NamedTuple, Optional, TYPE_CHECKING, Union +from . import ReactionButton, ReactionMenu, ViewMenu +from enum import Enum +from .abc import _BaseButton + +""" +MIT License + +Copyright (c) 2021-present @defxult + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +if TYPE_CHECKING: + ... +class _Details(NamedTuple): + """Used for buttons with a `custom_id` of `ID_CALLER`""" + func: Callable[..., None] + args: Iterable[Any] + kwargs: Dict[str, Any] + ... + + +Details = _Details +class ViewButton(discord.ui.Button, _BaseButton): + """A helper class for :class:`ViewMenu`. Represents a UI button. + + Parameters + ---------- + style: :class:`discord.ButtonStyle` + The style of the button + + label: Optional[:class:`str`] + The button label, if any + + disabled: :class:`bool` + Whether the button is disabled or not + + custom_id: Optional[:class:`str`] + The ID of the button that gets received during an interaction. If this button is for a URL, it does not have a custom ID + + url: Optional[:class:`str`] + The URL this button sends you to + + emoji: Optional[Union[:class:`str`, :class:`discord.PartialEmoji`]] + The emoji of the button, if available + + followup: Optional[:class:`ViewButton.Follow`] + Used with buttons with custom_id :attr:`ViewButton.ID_CALLER`, :attr:`ViewButton.ID_SEND_MESSAGE`, :attr:`ViewButton.ID_CUSTOM_EMBED` + + event: Optional[:class:`ViewButton.Event`] + Set the button to be disabled or removed when it has been pressed a certain amount of times + + Kwargs + ------ + name: :class:`str` + An optional name for the button. Can be set to retrieve it later via :meth:`ViewMenu.get_button()` + + skip: :class:`ViewButton.Skip` + Set the action and the amount of pages to skip when using a `custom_id` of `ViewButton.ID_SKIP` + + persist: :class:`bool` + Available only when using link buttons. This prevents link buttons from being disabled/removed when the menu times out or is stopped so they can remain clickable + + .. added v3.1.0 + :param:`persist` + """ + ID_NEXT_PAGE: Final[str] = ... + ID_PREVIOUS_PAGE: Final[str] = ... + ID_GO_TO_FIRST_PAGE: Final[str] = ... + ID_GO_TO_LAST_PAGE: Final[str] = ... + ID_GO_TO_PAGE: Final[str] = ... + ID_END_SESSION: Final[str] = ... + ID_CALLER: Final[str] = ... + ID_SEND_MESSAGE: Final[str] = ... + ID_CUSTOM_EMBED: Final[str] = ... + ID_SKIP: Final[str] = ... + _RE_IDs = ... + _RE_UNIQUE_ID_SET = ... + def __init__(self, *, style: discord.ButtonStyle = ..., label: Optional[str] = ..., disabled: bool = ..., custom_id: Optional[str] = ..., url: Optional[str] = ..., emoji: Optional[Union[str, discord.PartialEmoji]] = ..., followup: Optional[ViewButton.Followup] = ..., event: Optional[ViewButton.Event] = ..., **kwargs) -> None: + ... + + def __repr__(self): # -> str: + ... + + async def callback(self, interaction: discord.Interaction) -> None: + """*INTERNAL USE ONLY* - The callback function from the button interaction. This should not be manually called""" + ... + + class Followup: + """A class that represents the message sent using a :class:`ViewButton`. Contains parameters similar to method `discord.abc.Messageable.send`. Only to be used with :class:`ViewButton` kwarg "followup". + It is to be noted that this should not be used with :class:`ViewButton` with a "style" of `discord.ButtonStyle.link` because link buttons do not send interaction events. + + Parameters + ---------- + content: Optional[:class:`str`] + Message to send + + embed: Optional[:class:`discord.Embed`] + Embed to send. Can also bet set for buttons with a custom_id of :attr:`ViewButton.ID_CUSTOM_EMBED` + + file: Optional[:class:`discord.File`] + File to send. If the :class:`ViewButton` custom_id is :attr:`ViewButton.ID_SEND_MESSAGE`, the file will be ignored because of discord API limitations + + tts: :class:`bool` + If discord should read the message aloud. Not valid for `ephemeral` messages + + allowed_mentions: Optional[:class:`discord.AllowedMentions`] + Controls the mentions being processed in the menu message. Not valid for `ephemeral` messages + + delete_after: Optional[Union[:class:`int`, :class:`float`]] + Amount of time to wait before the message is deleted. Not valid for `ephemeral` messages + + ephemeral: :class:`bool` + If the message will be hidden from everyone except the person that pressed the button. This is only valid for a :class:`ViewButton` with custom_id :attr:`ViewButton.ID_SEND_MESSAGE` + + Kwargs + ------ + details: :meth:`ViewButton.Followup.set_caller_details()` + The information that will be used when a `ViewButton.ID_CALLER` button is pressed (defaults to :class:`None`) + """ + __slots__ = ... + def __repr__(self): # -> LiteralString: + ... + + def __init__(self, content: Optional[str] = ..., *, embed: Optional[discord.Embed] = ..., file: Optional[discord.File] = ..., tts: bool = ..., allowed_mentions: Optional[discord.AllowedMentions] = ..., delete_after: Optional[Union[int, float]] = ..., ephemeral: bool = ..., **kwargs) -> None: + ... + + @staticmethod + def set_caller_details(func: Callable[..., None], *args, **kwargs) -> Details: + """|static method| + + Set the parameters for the function you set for a :class:`ViewButton` with the custom_id :attr:`ViewButton.ID_CALLER` + + Parameters + ---------- + func: Callable[..., :class:`None`] + The function object that will be called when the associated button is pressed + + *args: `Any` + An argument list that represents the parameters of that function + + **kwargs: `Any` + An argument list that represents the kwarg parameters of that function + + Returns + ------- + :class:`Details`: The :class:`NamedTuple` containing the values needed to internally call the function you have set + + Raises + ------ + - `IncorrectType`: Parameter "func" was not a callable object + """ + ... + + + + @property + def menu(self) -> Optional[ViewMenu]: + """ + Returns + ------- + Optional[:class:`ViewMenu`]: The menu instance this button is attached to. Could be :class:`None` if the button is not attached to a menu + """ + ... + + @classmethod + def generate_skip(cls, label: str, action: Literal['+', '-'], amount: int) -> ViewButton: + """|class method| + + A factory method that returns a :class:`ViewButton` with the following parameters set: + + - style: `discord.ButtonStyle.gray` + - label: `