From f87f8a0b398bfdbc26291e6e512601fe04015955 Mon Sep 17 00:00:00 2001 From: wlinator Date: Thu, 29 Aug 2024 04:48:14 -0400 Subject: [PATCH] Huge refactor --- Dockerfile | 2 +- db/__init__.py | 0 db/database.py | 111 ++++++++++----------- db/migrations/__init__.py | 0 handlers/__init__.py | 0 handlers/error.py | 45 ++++----- lib/__init__.py | 0 lib/client.py | 15 +-- lib/const.py | 171 ++++++++++++++++---------------- lib/exceptions.py | 6 +- lib/loader.py | 18 ++-- locales/strings.en-US.json | 7 ++ main.py | 6 +- modules/__init__.py | 0 modules/admin/__init__.py | 0 modules/admin/sync.py | 10 +- modules/levels/__init__.py | 0 modules/levels/leaderboard.py | 17 ++-- modules/levels/level.py | 7 +- modules/misc/__init__.py | 0 modules/misc/avatar.py | 23 ++--- modules/misc/backup.py | 56 ++++++----- modules/misc/info.py | 12 ++- modules/misc/introduction.py | 172 ++++++++++++++++----------------- modules/misc/invite.py | 5 +- modules/misc/ping.py | 5 +- modules/misc/uptime.py | 15 +-- modules/misc/xkcd.py | 20 ++-- modules/moderation/__init__.py | 0 modules/moderation/slowmode.py | 75 ++++++++++++++ poetry.lock | 142 ++++++++++++++++++++++++++- pyproject.toml | 137 ++++++++++++++++++++++++-- services/__init__.py | 0 services/currency_service.py | 47 ++++----- services/daily_service.py | 29 ++---- services/xp_service.py | 37 ++++--- settings.yaml | 4 +- ui/__init__.py | 0 ui/embeds.py | 34 +++---- ui/views/__init__.py | 0 ui/views/blackjack.py | 15 ++- ui/views/introduction.py | 14 ++- ui/views/invite.py | 3 +- ui/views/leaderboard.py | 105 +++++++++++--------- wrappers/__init__.py | 0 wrappers/xkcd.py | 4 +- 46 files changed, 853 insertions(+), 516 deletions(-) create mode 100644 db/__init__.py create mode 100644 db/migrations/__init__.py create mode 100644 handlers/__init__.py create mode 100644 lib/__init__.py create mode 100644 modules/__init__.py create mode 100644 modules/admin/__init__.py create mode 100644 modules/levels/__init__.py create mode 100644 modules/misc/__init__.py create mode 100644 modules/moderation/__init__.py create mode 100644 modules/moderation/slowmode.py create mode 100644 services/__init__.py create mode 100644 ui/__init__.py create mode 100644 ui/views/__init__.py create mode 100644 wrappers/__init__.py diff --git a/Dockerfile b/Dockerfile index 8dc36ee..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", "-OO", "./main.py" ] \ No newline at end of file +CMD [ "poetry", "run", "python", "-O", "./main.py" ] \ No newline at end of file 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 ba8f1e1..14868b1 100644 --- a/db/database.py +++ b/db/database.py @@ -1,6 +1,7 @@ import os import pathlib import re +from typing import Any import mysql.connector from loguru import logger @@ -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 - - # 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 + 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.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/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/error.py b/handlers/error.py index 237857a..7494a28 100644 --- a/handlers/error.py +++ b/handlers/error.py @@ -1,20 +1,20 @@ import sys import traceback +from typing import Any from discord.ext import commands -from discord.ext.commands import Cog from loguru import logger +from lib import exceptions from lib.const import CONST -from ui.embeds import builder -from lib import exceptions as LumiExceptions +from ui.embeds import Builder async def handle_error( ctx: commands.Context[commands.Bot], error: commands.CommandError | commands.CheckFailure, ) -> None: - if isinstance(error, (commands.CommandNotFound, LumiExceptions.Blacklisted)): + if isinstance(error, commands.CommandNotFound | exceptions.Blacklisted): return author_text = None @@ -22,11 +22,7 @@ async def handle_error( 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): + if isinstance(error, commands.MissingRequiredArgument | commands.BadArgument): author_text = CONST.STRINGS["error_bad_argument_author"] description = CONST.STRINGS["error_bad_argument_description"].format(str(error)) @@ -58,12 +54,12 @@ async def handle_error( author_text = CONST.STRINGS["error_private_message_only_author"] description = CONST.STRINGS["error_private_message_only_description"] - elif isinstance(error, LumiExceptions.BirthdaysDisabled): + elif isinstance(error, exceptions.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): + elif isinstance(error, exceptions.LumiException): author_text = CONST.STRINGS["error_lumi_exception_author"] description = CONST.STRINGS["error_lumi_exception_description"].format( str(error), @@ -74,7 +70,7 @@ async def handle_error( description = CONST.STRINGS["error_unknown_error_description"] await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="error", user_name=ctx.author.name, author_text=author_text, @@ -85,7 +81,7 @@ async def handle_error( ) -async def on_error(event: str, *args, **kwargs) -> None: +async def on_error(event: str, *args: Any, **kwargs: Any) -> None: logger.exception( f"on_error INFO: errors.event.{event} | '*args': {args} | '**kwargs': {kwargs}", ) @@ -93,20 +89,25 @@ async def on_error(event: str, *args, **kwargs) -> None: traceback.print_exc() -class ErrorHandler(Cog): +class ErrorHandler(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot @staticmethod - async def log_command_error(ctx, error, command_type): - log_msg = ( - f"{ctx.author.name} executed {command_type}{ctx.command.qualified_name}" - ) + async def log_command_error( + ctx: commands.Context[commands.Bot], + error: commands.CommandError | commands.CheckFailure, + command_type: str, + ) -> None: + if ctx.command is None: + return + + 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.exception(f"{log_msg} | FAILED: {error}") - @Cog.listener() + @commands.Cog.listener() async def on_command_error( self, ctx: commands.Context[commands.Bot], @@ -119,7 +120,7 @@ class ErrorHandler(Cog): logger.exception(f"Error in on_command_error: {e}") traceback.print_exc() - @Cog.listener() + @commands.Cog.listener() async def on_app_command_error( self, ctx: commands.Context[commands.Bot], @@ -132,8 +133,8 @@ class ErrorHandler(Cog): logger.exception(f"Error in on_app_command_error: {e}") traceback.print_exc() - @Cog.listener() - async def on_error(self, event: str, *args, **kwargs) -> None: + @commands.Cog.listener() + async def on_error(self, event: str, *args: Any, **kwargs: Any) -> None: await on_error(event, *args, **kwargs) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/client.py b/lib/client.py index fedbe66..e4a1154 100644 --- a/lib/client.py +++ b/lib/client.py @@ -1,15 +1,18 @@ +import asyncio +from typing import Any + from discord.ext import commands from loguru import logger -import asyncio -from lib.loader import CogLoader + from db.database import run_migrations +from lib.loader import CogLoader class Luminara(commands.Bot): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.is_shutting_down: bool = False - self.setup_task: asyncio.Task = asyncio.create_task(self.setup()) + self.setup_task: asyncio.Task[None] = asyncio.create_task(self.setup()) self.strip_after_prefix = True self.case_insensitive = True @@ -47,9 +50,7 @@ class Luminara(commands.Bot): await self.close() - if tasks := [ - task for task in asyncio.all_tasks() if task is not asyncio.current_task() - ]: + 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: diff --git a/lib/const.py b/lib/const.py index d56cca6..d87ed42 100644 --- a/lib/const.py +++ b/lib/const.py @@ -1,127 +1,132 @@ -import os import json +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any, Final + import yaml -from functools import lru_cache -from typing import Optional, Callable, Set, List, Dict -class _parser: +class Parser: """Internal parses class. Not intended to be used outside of this module.""" - @lru_cache(maxsize=1024) - def read_s(self) -> dict: - return self._read_file("settings.yaml", yaml.safe_load) + def __init__(self): + self._cache: dict[str, Any] = {} - def read_json(self, path: str) -> dict: - return self._read_file(f"locales/{path}.json", json.load) + 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_file(self, file_path: str, load_func: Callable) -> dict: - with open(file_path) as file: + 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 = _parser() - _s = _parser().read_s() +class Constants: + _p: Final = Parser() + _s: Final = Parser().read_s() # bot credentials - 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") + TOKEN: Final[str | None] = os.environ.get("TOKEN") + INSTANCE: Final[str | None] = os.environ.get("INSTANCE") + 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") - OWNER_IDS: Optional[Set[int]] = ( - {int(id.strip()) for id in os.environ.get("OWNER_IDS", "").split(",") if id} + OWNER_IDS: Final[set[int] | None] = ( + {int(owner_id.strip()) for owner_id in os.environ.get("OWNER_IDS", "").split(",") if owner_id} if "OWNER_IDS" in os.environ else None ) # metadata - TITLE: str = _s["info"]["title"] - AUTHOR: str = _s["info"]["author"] - LICENSE: str = _s["info"]["license"] - VERSION: str = _s["info"]["version"] - REPO_URL: str = _s["info"]["repository_url"] - INVITE_URL: str = _s["info"]["invite_url"] + 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: str = _s["logs"]["level"] or "DEBUG" - LOG_FORMAT: str = _s["logs"]["format"] + LOG_LEVEL: Final[str] = _s["logs"]["level"] or "DEBUG" + LOG_FORMAT: Final[str] = _s["logs"]["format"] # cogs - COG_IGNORE_LIST: Set[str] = ( - set(_s["cogs"]["ignore"]) if _s["cogs"]["ignore"] else set() - ) + COG_IGNORE_LIST: Final[set[str]] = set(_s["cogs"]["ignore"]) if _s["cogs"]["ignore"] else set() # images - ALLOWED_IMAGE_EXTENSIONS: List[str] = _s["images"]["allowed_image_extensions"] - BIRTHDAY_GIF_URL: str = _s["images"]["birthday_gif_url"] + ALLOWED_IMAGE_EXTENSIONS: Final[list[str]] = _s["images"]["allowed_image_extensions"] + BIRTHDAY_GIF_URL: Final[str] = _s["images"]["birthday_gif_url"] # colors - COLOR_DEFAULT: int = _s["colors"]["color_default"] - COLOR_WARNING: int = _s["colors"]["color_warning"] - COLOR_ERROR: int = _s["colors"]["color_error"] + 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: int = _s["economy"]["daily_reward"] - BLACKJACK_MULTIPLIER: float = _s["economy"]["blackjack_multiplier"] - BLACKJACK_HIT_EMOJI: str = _s["economy"]["blackjack_hit_emoji"] - BLACKJACK_STAND_EMOJI: str = _s["economy"]["blackjack_stand_emoji"] - SLOTS_MULTIPLIERS: Dict[str, float] = _s["economy"]["slots_multipliers"] + 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: str = _s["art"]["fetch_url"] + _fetch_url: Final[str] = _s["art"]["fetch_url"] - LUMI_LOGO_OPAQUE: str = _fetch_url + _s["art"]["logo"]["opaque"] - LUMI_LOGO_TRANSPARENT: str = _fetch_url + _s["art"]["logo"]["transparent"] - BOOST_ICON: str = _fetch_url + _s["art"]["icons"]["boost"] - CHECK_ICON: str = _fetch_url + _s["art"]["icons"]["check"] - CROSS_ICON: str = _fetch_url + _s["art"]["icons"]["cross"] - EXCLAIM_ICON: str = _fetch_url + _s["art"]["icons"]["exclaim"] - INFO_ICON: str = _fetch_url + _s["art"]["icons"]["info"] - HAMMER_ICON: str = _fetch_url + _s["art"]["icons"]["hammer"] - MONEY_BAG_ICON: str = _fetch_url + _s["art"]["icons"]["money_bag"] - MONEY_COINS_ICON: str = _fetch_url + _s["art"]["icons"]["money_coins"] - QUESTION_ICON: str = _fetch_url + _s["art"]["icons"]["question"] - STREAK_ICON: str = _fetch_url + _s["art"]["icons"]["streak"] - STREAK_BRONZE_ICON: str = _fetch_url + _s["art"]["icons"]["streak_bronze"] - STREAK_GOLD_ICON: str = _fetch_url + _s["art"]["icons"]["streak_gold"] - STREAK_SILVER_ICON: str = _fetch_url + _s["art"]["icons"]["streak_silver"] - WARNING_ICON: str = _fetch_url + _s["art"]["icons"]["warning"] + 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: str = _s["art"]["juicybblue"]["flowers"] - TEAPOT_ART: str = _s["art"]["juicybblue"]["teapot"] - MUFFIN_ART: str = _s["art"]["juicybblue"]["muffin"] - CLOUD_ART: str = _s["art"]["other"]["cloud"] - TROPHY_ART: str = _s["art"]["other"]["trophy"] + 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: int = _s["emotes"]["guild_id"] - EMOTE_IDS: Dict[str, int] = _s["emotes"]["emote_ids"] + 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: int = _s["introductions"]["intro_guild_id"] - INTRODUCTIONS_CHANNEL_ID: int = _s["introductions"]["intro_channel_id"] - INTRODUCTIONS_QUESTION_MAPPING: Dict[str, str] = _s["introductions"][ - "intro_question_mapping" - ] + 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 = _p.read_json("strings.en-US") - LEVEL_MESSAGES = _p.read_json("levels.en-US") + STRINGS: Final = _p.read_json("strings.en-US") + LEVEL_MESSAGES: Final = _p.read_json("levels.en-US") - _bday = _p.read_json("bdays.en-US") - BIRTHDAY_MESSAGES = _bday["birthday_messages"] - BIRTHDAY_MONTHS = _bday["months"] + _bday: Final = _p.read_json("bdays.en-US") + BIRTHDAY_MESSAGES: Final = _bday["birthday_messages"] + BIRTHDAY_MONTHS: Final = _bday["months"] -CONST: _constants = _constants() +CONST = Constants() diff --git a/lib/exceptions.py b/lib/exceptions.py index 47d5b1a..4dda849 100644 --- a/lib/exceptions.py +++ b/lib/exceptions.py @@ -8,15 +8,13 @@ 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"]): + def __init__(self, message: str = CONST.STRINGS["lumi_exception_generic"]): self.message = message super().__init__(message) @@ -26,6 +24,6 @@ class Blacklisted(commands.CommandError): Raised when a user is blacklisted. """ - def __init__(self, message=CONST.STRINGS["lumi_exception_blacklisted"]): + def __init__(self, message: str = CONST.STRINGS["lumi_exception_blacklisted"]): self.message = message super().__init__(message) diff --git a/lib/loader.py b/lib/loader.py index fdbed86..21d7e4f 100644 --- a/lib/loader.py +++ b/lib/loader.py @@ -1,8 +1,10 @@ -from loguru import logger -from discord.ext import commands -from lib.const import CONST 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): @@ -17,11 +19,7 @@ class CogLoader(commands.Cog): 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) - ) + 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: @@ -34,9 +32,7 @@ class CogLoader(commands.Cog): elif await self.is_cog(path): relative_path: Path = path.relative_to(Path(__file__).parent.parent) - module: str = ( - str(relative_path).replace("/", ".").replace("\\", ".")[:-3] - ) + module: str = str(relative_path).replace("/", ".").replace("\\", ".")[:-3] try: await self.bot.load_extension(name=module) logger.debug(f"Loaded cog: {module}") diff --git a/locales/strings.en-US.json b/locales/strings.en-US.json index 874839a..168a2c2 100644 --- a/locales/strings.en-US.json +++ b/locales/strings.en-US.json @@ -252,6 +252,13 @@ "ping_footer": "Latency: {0}ms", "ping_pong": "pong!", "ping_uptime": "I've been online since .", + "slowmode_author": "Slowmode", + "slowmode_channel_not_found": "Channel not found.", + "slowmode_duration_not_found": "Please provide a duration in seconds for the slowmode.", + "slowmode_footer": "Use 0 to disable slowmode", + "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} seconds 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}**.", "sync_author": "Synced Commands", diff --git a/main.py b/main.py index a1714a0..ce8bc98 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,12 @@ -import sys import asyncio +import sys + import discord from discord.ext import commands from loguru import logger -from lib.const import CONST + from lib.client import Luminara +from lib.const import CONST logger.remove() logger.add(sys.stdout, format=CONST.LOG_FORMAT, colorize=True, level=CONST.LOG_LEVEL) 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 new file mode 100644 index 0000000..e69de29 diff --git a/modules/admin/sync.py b/modules/admin/sync.py index a994565..91ec177 100644 --- a/modules/admin/sync.py +++ b/modules/admin/sync.py @@ -1,8 +1,8 @@ -from discord.ext import commands import discord -from typing import Optional -from ui.embeds import builder +from discord.ext import commands + from lib.const import CONST +from ui.embeds import Builder class Sync(commands.Cog): @@ -18,7 +18,7 @@ class Sync(commands.Cog): async def sync( self, ctx: commands.Context[commands.Bot], - guild: Optional[discord.Guild] = None, + guild: discord.Guild | None = None, ) -> None: if not guild: guild = ctx.guild @@ -28,7 +28,7 @@ class Sync(commands.Cog): self.bot.tree.copy_global_to(guild=guild) await self.bot.tree.sync(guild=guild) - embed = builder.create_embed( + embed = Builder.create_embed( theme="success", user_name=ctx.author.name, author_text=CONST.STRINGS["sync_author"], diff --git a/modules/levels/__init__.py b/modules/levels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/levels/leaderboard.py b/modules/levels/leaderboard.py index 015cefd..eec0f3f 100644 --- a/modules/levels/leaderboard.py +++ b/modules/levels/leaderboard.py @@ -1,8 +1,10 @@ -from typing import Optional +from typing import cast + +from discord import Embed, Guild, Member from discord.ext import commands -from discord import Embed, Guild + from lib.const import CONST -from ui.embeds import builder +from ui.embeds import Builder from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView @@ -16,17 +18,18 @@ class Leaderboard(commands.Cog): usage="leaderboard", ) async def leaderboard(self, ctx: commands.Context[commands.Bot]) -> None: - guild: Optional[Guild] = ctx.guild + guild: Guild | None = ctx.guild if not guild: return options: LeaderboardCommandOptions = LeaderboardCommandOptions() view: LeaderboardCommandView = LeaderboardCommandView(ctx, options) - embed: Embed = builder.create_embed( + author: Member = cast(Member, ctx.author) + embed: Embed = Builder.create_embed( theme="info", - user_name=ctx.author.name, - thumbnail_url=ctx.author.display_avatar.url, + user_name=author.name, + thumbnail_url=author.display_avatar.url, hide_name_in_description=True, ) diff --git a/modules/levels/level.py b/modules/levels/level.py index 6937f7c..8eb19be 100644 --- a/modules/levels/level.py +++ b/modules/levels/level.py @@ -1,8 +1,9 @@ -from discord.ext import commands from discord import Embed +from discord.ext import commands + from lib.const import CONST -from ui.embeds import builder from services.xp_service import XpService +from ui.embeds import Builder class Level(commands.Cog): @@ -25,7 +26,7 @@ class Level(commands.Cog): xp_data.level, ) - embed: Embed = builder.create_embed( + embed: Embed = Builder.create_embed( theme="success", user_name=ctx.author.name, title=CONST.STRINGS["xp_level"].format(xp_data.level), diff --git a/modules/misc/__init__.py b/modules/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/misc/avatar.py b/modules/misc/avatar.py index 160e52a..6c94f94 100644 --- a/modules/misc/avatar.py +++ b/modules/misc/avatar.py @@ -1,10 +1,9 @@ from io import BytesIO -from discord.ext import commands -from discord.ext.commands import MemberConverter -from typing import Optional + import discord -from discord import File import httpx +from discord import File +from discord.ext import commands async def create_avatar_file(url: str) -> File: @@ -42,7 +41,7 @@ class Avatar(commands.Cog): async def avatar( self, ctx: commands.Context[commands.Bot], - member: Optional[discord.Member] = None, + member: discord.Member | None = None, ) -> None: """ Get the avatar of a member. @@ -55,18 +54,12 @@ class Avatar(commands.Cog): The member to get the avatar of. """ if member is None: - member = await MemberConverter().convert(ctx, str(ctx.author.id)) + member = await commands.MemberConverter().convert(ctx, str(ctx.author.id)) - 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 + 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 - ] + files: list[File] = [await create_avatar_file(avatar) for avatar in [guild_avatar, profile_avatar] if avatar] if files: await ctx.send(files=files) diff --git a/modules/misc/backup.py b/modules/misc/backup.py index a80ff56..fa3545f 100644 --- a/modules/misc/backup.py +++ b/modules/misc/backup.py @@ -1,20 +1,24 @@ import subprocess from datetime import datetime -from typing import List, Optional +from pathlib import Path +import dropbox # type: ignore +import pytz from discord.ext import commands, tasks -import dropbox -from dropbox.files import FileMetadata +from dropbox.files import FileMetadata # type: ignore from loguru import logger from lib.const import CONST +# Initialize timezone +tz = pytz.timezone("America/New_York") + # 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, @@ -23,36 +27,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.now().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(tz).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 # type: ignore - if isinstance(entry, FileMetadata) - ] + 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(f"/{file}") + _dbx.files_delete_v2(f"/{file}") # type: ignore async def backup() -> None: diff --git a/modules/misc/info.py b/modules/misc/info.py index e84980b..1d210ea 100644 --- a/modules/misc/info.py +++ b/modules/misc/info.py @@ -1,10 +1,12 @@ -from discord.ext import commands -from lib.const import CONST -from ui.embeds import builder -import discord import os import platform + +import discord import psutil +from discord.ext import commands + +from lib.const import CONST +from ui.embeds import Builder class Info(commands.Cog): @@ -29,7 +31,7 @@ class Info(commands.Cog): ], ) - embed: discord.Embed = builder.create_embed( + embed: discord.Embed = Builder.create_embed( theme="info", user_name=ctx.author.name, author_text=f"{CONST.TITLE} v{CONST.VERSION}", diff --git a/modules/misc/introduction.py b/modules/misc/introduction.py index b1aa928..53254a6 100644 --- a/modules/misc/introduction.py +++ b/modules/misc/introduction.py @@ -1,11 +1,8 @@ -import asyncio -from typing import Dict, Optional - import discord from discord.ext import commands from lib.const import CONST -from ui.embeds import builder +from ui.embeds import Builder from ui.views.introduction import ( IntroductionFinishButtons, IntroductionStartButtons, @@ -22,16 +19,14 @@ class Introduction(commands.Cog): usage="introduction", ) async def introduction(self, ctx: commands.Context[commands.Bot]) -> None: - guild: Optional[discord.Guild] = self.bot.get_guild( + guild: discord.Guild | None = self.bot.get_guild( CONST.INTRODUCTIONS_GUILD_ID, ) - member: Optional[discord.Member] = ( - guild.get_member(ctx.author.id) if guild else None - ) + member: discord.Member | None = guild.get_member(ctx.author.id) if guild else None if not guild or not member: await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="error", user_name=ctx.author.name, author_text=CONST.STRINGS["intro_no_guild_author"], @@ -41,17 +36,17 @@ class Introduction(commands.Cog): ) return - question_mapping: Dict[str, str] = CONST.INTRODUCTIONS_QUESTION_MAPPING - channel: Optional[discord.abc.GuildChannel] = guild.get_channel( + question_mapping: dict[str, str] = CONST.INTRODUCTIONS_QUESTION_MAPPING + channel: discord.abc.GuildChannel | None = guild.get_channel( CONST.INTRODUCTIONS_CHANNEL_ID, ) if not channel or isinstance( channel, - (discord.ForumChannel, discord.CategoryChannel), + discord.ForumChannel | discord.CategoryChannel, ): await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="error", user_name=ctx.author.name, author_text=CONST.STRINGS["intro_no_channel_author"], @@ -61,11 +56,9 @@ class Introduction(commands.Cog): ) return - view: IntroductionStartButtons | IntroductionFinishButtons = ( - IntroductionStartButtons(ctx) - ) + view: IntroductionStartButtons | IntroductionFinishButtons = IntroductionStartButtons(ctx) await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="info", user_name=ctx.author.name, author_text=CONST.STRINGS["intro_service_name"], @@ -79,7 +72,7 @@ class Introduction(commands.Cog): if view.clicked_stop: await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="error", user_name=ctx.author.name, author_text=CONST.STRINGS["intro_stopped_author"], @@ -97,96 +90,95 @@ class Introduction(commands.Cog): discord.DMChannel, ) - answer_mapping: Dict[str, str] = {} + 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, + 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"], + ), ) - answer_content: str = answer.content.replace("\n", " ") - if len(answer_content) > 200: + 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( + 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"], + author_text=CONST.STRINGS["intro_timeout_author"], + description=CONST.STRINGS["intro_timeout"], footer_text=CONST.STRINGS["intro_service_name"], ), ) return - answer_mapping[key] = answer_content + description: str = "".join( + CONST.STRINGS["intro_preview_field"].format(key, value) for key, value in answer_mapping.items() + ) - except asyncio.TimeoutError: + 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( + 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_timeout_author"], - description=CONST.STRINGS["intro_timeout"], + author_text=CONST.STRINGS["intro_stopped_author"], + description=CONST.STRINGS["intro_stopped"], 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: diff --git a/modules/misc/invite.py b/modules/misc/invite.py index 8f3b4e8..c7fe509 100644 --- a/modules/misc/invite.py +++ b/modules/misc/invite.py @@ -1,6 +1,7 @@ from discord.ext import commands + from lib.const import CONST -from ui.embeds import builder +from ui.embeds import Builder from ui.views.invite import InviteButton @@ -15,7 +16,7 @@ class Invite(commands.Cog): ) async def invite(self, ctx: commands.Context[commands.Bot]) -> None: await ctx.send( - embed=builder.create_embed( + embed=Builder.create_embed( theme="success", user_name=ctx.author.name, author_text=CONST.STRINGS["invite_author"], diff --git a/modules/misc/ping.py b/modules/misc/ping.py index e89e2f1..8b51b30 100644 --- a/modules/misc/ping.py +++ b/modules/misc/ping.py @@ -1,6 +1,7 @@ from discord.ext import commands + from lib.const import CONST -from ui.embeds import builder +from ui.embeds import Builder class Ping(commands.Cog): @@ -12,7 +13,7 @@ class Ping(commands.Cog): usage="ping", ) async def ping(self, ctx: commands.Context[commands.Bot]) -> None: - embed = builder.create_embed( + embed = Builder.create_embed( theme="success", user_name=ctx.author.name, author_text=CONST.STRINGS["ping_author"], diff --git a/modules/misc/uptime.py b/modules/misc/uptime.py index 0923d63..acf84e9 100644 --- a/modules/misc/uptime.py +++ b/modules/misc/uptime.py @@ -1,14 +1,17 @@ -from discord.ext import commands -from discord import Embed -from lib.const import CONST -from ui.embeds import builder from datetime import datetime +import discord +from discord import Embed +from discord.ext import commands + +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 = datetime.now() + self.start_time: datetime = discord.utils.utcnow() @commands.hybrid_command( name="uptime", @@ -17,7 +20,7 @@ class Uptime(commands.Cog): async def uptime(self, ctx: commands.Context[commands.Bot]) -> None: unix_timestamp: int = int(self.start_time.timestamp()) - embed: Embed = builder.create_embed( + embed: Embed = Builder.create_embed( theme="info", user_name=ctx.author.name, author_text=CONST.STRINGS["ping_author"], diff --git a/modules/misc/xkcd.py b/modules/misc/xkcd.py index 24095b5..282d947 100644 --- a/modules/misc/xkcd.py +++ b/modules/misc/xkcd.py @@ -1,10 +1,10 @@ -from discord.ext import commands -from lib.const import CONST -from ui.embeds import builder -from discord import app_commands import discord +from discord import app_commands +from discord.ext import commands + +from lib.const import CONST +from ui.embeds import Builder from wrappers.xkcd import Client, HttpError -from typing import Optional _xkcd = Client() @@ -12,7 +12,7 @@ _xkcd = Client() async def print_comic( interaction: discord.Interaction, latest: bool = False, - number: Optional[int] = None, + number: int | None = None, ) -> None: try: if latest: @@ -23,7 +23,7 @@ async def print_comic( comic = _xkcd.get_random_comic(raw_comic_image=True) await interaction.response.send_message( - embed=builder.create_embed( + embed=Builder.create_embed( theme="info", author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title), description=CONST.STRINGS["xkcd_description"].format( @@ -37,7 +37,7 @@ async def print_comic( except HttpError: await interaction.response.send_message( - embed=builder.create_embed( + embed=Builder.create_embed( theme="error", author_text=CONST.STRINGS["xkcd_not_found_author"], description=CONST.STRINGS["xkcd_not_found"], @@ -61,8 +61,8 @@ class Xkcd(commands.Cog): await print_comic(interaction) @xkcd.command(name="search", description="Search for an xkcd comic") - async def xkcd_search(self, interaction: discord.Interaction, id: int) -> None: - await print_comic(interaction, number=id) + async def xkcd_search(self, interaction: discord.Interaction, comic_id: int) -> None: + await print_comic(interaction, number=comic_id) async def setup(bot: commands.Bot) -> None: diff --git a/modules/moderation/__init__.py b/modules/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/moderation/slowmode.py b/modules/moderation/slowmode.py new file mode 100644 index 0000000..e213e30 --- /dev/null +++ b/modules/moderation/slowmode.py @@ -0,0 +1,75 @@ +import contextlib + +import discord +from discord.ext import commands + +from lib.const import CONST +from ui.embeds import Builder + + +class Slowmode(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command( + name="slowmode", + aliases=["sm"], + usage="slowmode ", + ) + @commands.has_permissions(manage_channels=True) + async def slowmode( + self, + ctx: commands.Context[commands.Bot], + arg1: str, + arg2: str, + ) -> None: + # define actual channel & duration + channel: discord.TextChannel | None = None + duration: int | None = None + + for arg in (arg1, arg2): + if not channel: + try: + channel = await commands.TextChannelConverter().convert(ctx, arg) + except commands.BadArgument: + with contextlib.suppress(ValueError): + duration = int(arg) + else: + with contextlib.suppress(ValueError): + duration = int(arg) + + if not channel: + await ctx.send(CONST.STRINGS["slowmode_channel_not_found"]) + return + + if duration is None: + await ctx.send(CONST.STRINGS["slowmode_duration_not_found"]) + return + + if duration < 0 or duration > 21600: # 21600 seconds = 6 hours (Discord's max slowmode) + await ctx.send("Slowmode duration must be between 0 and 21600 seconds.") + return + + try: + await channel.edit(slowmode_delay=duration) + embed = Builder.create_embed( + theme="success", + user_name=ctx.author.name, + author_text=CONST.STRINGS["slowmode_author"], + description=CONST.STRINGS["slowmode_success"].format(duration, channel.mention), + footer_text=CONST.STRINGS["slowmode_footer"], + ) + except discord.Forbidden: + embed = Builder.create_embed( + theme="error", + user_name=ctx.author.name, + author_text=CONST.STRINGS["slowmode_author"], + description=CONST.STRINGS["slowmode_forbidden"], + footer_text=CONST.STRINGS["slowmode_footer"], + ) + + await ctx.send(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Slowmode(bot)) diff --git a/poetry.lock b/poetry.lock index 142432f..c61b3d2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -174,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" @@ -820,15 +831,138 @@ files = [ [package.extras] test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +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] +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] @@ -1165,4 +1299,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1bec4428d16328dd4054cda20654e446c54aa0463b79ef32ae4cfa10de7c0dfd" +content-hash = "e6ab702cf6efc2ec25a9c033029869f9c4c3631e2e6063c4241153ea8c1f8e79" diff --git a/pyproject.toml b/pyproject.toml index c86138b..810a6b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,23 +8,148 @@ readme = "README.md" 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.2" loguru = "^0.7.2" mysql-connector-python = "^9.0.0" pre-commit = "^3.8.0" +psutil = "^6.0.0" pyright = "^1.1.377" python = "^3.12" +pytz = "^2024.1" pyyaml = "^6.0.2" ruff = "^0.6.2" typing-extensions = "^4.12.2" -aiofiles = "^24.1.0" -aiocache = "^0.12.2" -aioconsole = "^0.7.1" -psutil = "^6.0.0" -dropbox = "^12.0.2" -pytz = "^2024.1" +pydantic = "^2.8.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/currency_service.py b/services/currency_service.py index 7b824c8..de71790 100644 --- a/services/currency_service.py +++ b/services/currency_service.py @@ -4,20 +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) - def push(self): - query = """ + def push(self) -> None: + query: str = """ UPDATE currency SET balance = %s WHERE user_id = %s @@ -26,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 @@ -52,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 133c549..1420a21 100644 --- a/services/daily_service.py +++ b/services/daily_service.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from typing import List, Optional, Tuple import pytz @@ -21,7 +20,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 +37,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 +50,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 +64,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 +90,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 +98,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/xp_service.py b/services/xp_service.py index 8f5f10d..14aa7c0 100644 --- a/services/xp_service.py +++ b/services/xp_service.py @@ -1,5 +1,5 @@ import time -from typing import Callable, Dict, List, Optional, Tuple +from collections.abc import Callable from discord.ext import commands @@ -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.yaml b/settings.yaml index bff4566..d7bcf20 100644 --- a/settings.yaml +++ b/settings.yaml @@ -92,8 +92,8 @@ info: 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? (nickname) Age: how old are you? diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/embeds.py b/ui/embeds.py index bd632c8..e6be2d4 100644 --- a/ui/embeds.py +++ b/ui/embeds.py @@ -1,28 +1,28 @@ from datetime import datetime -from typing import Optional, Literal +from typing import Literal import discord from lib.const import CONST -class builder: +class Builder: @staticmethod def create_embed( - user_name: Optional[str] = None, - user_display_avatar_url: Optional[str] = None, - theme: Optional[Literal["error", "success", "info", "warning"]] = None, - title: Optional[str] = None, - author_text: Optional[str] = None, - author_icon_url: Optional[str] = None, - author_url: Optional[str] = None, - description: Optional[str] = None, - color: Optional[int] = None, - footer_text: Optional[str] = None, - footer_icon_url: Optional[str] = None, - image_url: Optional[str] = None, - thumbnail_url: Optional[str] = None, - timestamp: Optional[datetime] = None, + user_name: str | None = None, + user_display_avatar_url: str | None = None, + theme: Literal["error", "success", "info", "warning"] | None = None, + title: str | None = None, + author_text: str | None = None, + author_icon_url: str | None = None, + author_url: str | None = None, + description: str | None = None, + color: int | None = None, + footer_text: str | None = None, + footer_icon_url: str | None = None, + image_url: str | None = None, + thumbnail_url: str | None = None, + timestamp: datetime | None = None, hide_name_in_description: bool = False, hide_time: bool = False, ) -> discord.Embed: @@ -59,7 +59,7 @@ class builder: icon_url=footer_icon_url or CONST.LUMI_LOGO_TRANSPARENT, ) - embed.timestamp = None if hide_time else (timestamp or datetime.now()) + embed.timestamp = None if hide_time else (timestamp or discord.utils.utcnow()) if image_url: embed.set_image(url=image_url) if thumbnail_url: diff --git a/ui/views/__init__.py b/ui/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/views/blackjack.py b/ui/views/blackjack.py index 756a6ce..394f948 100644 --- a/ui/views/blackjack.py +++ b/ui/views/blackjack.py @@ -1,8 +1,8 @@ import discord from discord.ext import commands -from discord.ui import View, Button +from discord.ui import Button, View + from lib.const import CONST -from typing import List, Optional class BlackJackButtons(View): @@ -12,14 +12,13 @@ class BlackJackButtons(View): self.clickedHit: bool = False self.clickedStand: bool = False self.clickedDoubleDown: bool = False - self.message: Optional[discord.Message] = None + self.message: discord.Message | None = None async def on_timeout(self) -> None: - self.children: List[discord.ui.Button] = [] + self.children: list[discord.ui.Button[View]] = [] for child in self.children: - if isinstance(child, Button): - child.disabled = True + child.disabled = True @discord.ui.button( label=CONST.STRINGS["blackjack_hit"], @@ -29,7 +28,7 @@ class BlackJackButtons(View): async def hit_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: self.clickedHit = True await interaction.response.defer() @@ -43,7 +42,7 @@ class BlackJackButtons(View): async def stand_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: self.clickedStand = True await interaction.response.defer() diff --git a/ui/views/introduction.py b/ui/views/introduction.py index 22cb10d..7e75c68 100644 --- a/ui/views/introduction.py +++ b/ui/views/introduction.py @@ -1,5 +1,3 @@ -from typing import Optional - import discord from discord.ext import commands from discord.ui import Button, View @@ -11,7 +9,7 @@ class IntroductionStartButtons(View): self.ctx: commands.Context[commands.Bot] = ctx self.clicked_start: bool = False self.clicked_stop: bool = False - self.message: Optional[discord.Message] = None + self.message: discord.Message | None = None async def on_timeout(self) -> None: for child in self.children: @@ -24,7 +22,7 @@ class IntroductionStartButtons(View): async def start_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: await interaction.response.edit_message(view=None) self.clicked_start = True @@ -34,7 +32,7 @@ class IntroductionStartButtons(View): async def stop_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: await interaction.response.edit_message(view=None) self.clicked_stop = True @@ -46,7 +44,7 @@ class IntroductionFinishButtons(View): super().__init__(timeout=60) self.ctx: commands.Context[commands.Bot] = ctx self.clicked_confirm: bool = False - self.message: Optional[discord.Message] = None + self.message: discord.Message | None = None async def on_timeout(self) -> None: for child in self.children: @@ -59,7 +57,7 @@ class IntroductionFinishButtons(View): async def confirm_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: await interaction.response.edit_message(view=None) self.clicked_confirm = True @@ -69,7 +67,7 @@ class IntroductionFinishButtons(View): async def stop_button_callback( self, interaction: discord.Interaction, - button: Button, + button: Button[View], ) -> None: await interaction.response.edit_message(view=None) self.stop() diff --git a/ui/views/invite.py b/ui/views/invite.py index 7c70095..a5d1219 100644 --- a/ui/views/invite.py +++ b/ui/views/invite.py @@ -1,12 +1,13 @@ from discord import ButtonStyle from discord.ui import Button, View + from lib.const import CONST class InviteButton(View): def __init__(self) -> None: super().__init__(timeout=None) - invite_button: Button = Button( + invite_button: Button[InviteButton] = Button( label=CONST.STRINGS["invite_button_text"], style=ButtonStyle.url, url=CONST.INVITE_URL, diff --git a/ui/views/leaderboard.py b/ui/views/leaderboard.py index 2e9b261..bb131f1 100644 --- a/ui/views/leaderboard.py +++ b/ui/views/leaderboard.py @@ -1,16 +1,17 @@ +import contextlib from datetime import datetime import discord from discord.ext import commands from lib.const import CONST -from ui.embeds import builder from services.currency_service import Currency from services.daily_service import Dailies from services.xp_service import XpService +from ui.embeds import Builder -class LeaderboardCommandOptions(discord.ui.Select): +class LeaderboardCommandOptions(discord.ui.Select[discord.ui.View]): """ This class specifies the options for the leaderboard command: - XP @@ -19,34 +20,35 @@ class LeaderboardCommandOptions(discord.ui.Select): """ def __init__(self) -> None: + options: list[discord.SelectOption] = [ + 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="📅", + ), + ] 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="📅", - ), - ], + options=options, ) async def callback(self, interaction: discord.Interaction) -> None: - if self.view: + if isinstance(self.view, LeaderboardCommandView): await self.view.on_select(self.values[0], interaction) @@ -56,15 +58,17 @@ class LeaderboardCommandView(discord.ui.View): what kind of leaderboard to show. """ + ctx: commands.Context[commands.Bot] + options: LeaderboardCommandOptions + def __init__( self, ctx: commands.Context[commands.Bot], options: LeaderboardCommandOptions, ) -> None: + super().__init__(timeout=180) self.ctx = ctx self.options = options - - super().__init__(timeout=180) self.add_item(self.options) async def on_timeout(self) -> None: @@ -72,7 +76,7 @@ class LeaderboardCommandView(discord.ui.View): async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user and interaction.user != self.ctx.author: - embed = builder.create_embed( + embed = Builder.create_embed( theme="error", user_name=interaction.user.name, description=CONST.STRINGS["xp_lb_cant_use_dropdown"], @@ -86,7 +90,7 @@ class LeaderboardCommandView(discord.ui.View): if not self.ctx.guild: return - embed = builder.create_embed( + embed = Builder.create_embed( theme="success", user_name=interaction.user.name, thumbnail_url=CONST.FLOWERS_ART, @@ -99,7 +103,12 @@ class LeaderboardCommandView(discord.ui.View): await interaction.response.edit_message(embed=embed) - async def populate_leaderboard(self, item: str, embed, icon): + async def populate_leaderboard( + self, + item: str, + embed: discord.Embed, + icon: str, + ) -> None: leaderboard_methods = { "xp": self._populate_xp_leaderboard, "currency": self._populate_currency_leaderboard, @@ -107,11 +116,13 @@ class LeaderboardCommandView(discord.ui.View): } await leaderboard_methods[item](embed, icon) - async def _populate_xp_leaderboard(self, embed, icon): + async def _populate_xp_leaderboard(self, embed: discord.Embed, icon: str) -> None: if not self.ctx.guild: return - xp_lb = XpService.load_leaderboard(self.ctx.guild.id) + xp_lb: list[tuple[int, int, int, int]] = 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( @@ -133,20 +144,22 @@ class LeaderboardCommandView(discord.ui.View): inline=False, ) - async def _populate_currency_leaderboard(self, embed, icon): + async def _populate_currency_leaderboard( + self, + embed: discord.Embed, + icon: str, + ) -> None: if not self.ctx.guild: return - cash_lb = Currency.load_leaderboard() + cash_lb: list[tuple[int, int, int]] = 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: discord.Member | None = None + with contextlib.suppress(discord.HTTPException): 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( @@ -157,29 +170,31 @@ class LeaderboardCommandView(discord.ui.View): inline=False, ) - async def _populate_dailies_leaderboard(self, embed, icon): + async def _populate_dailies_leaderboard( + self, + embed: discord.Embed, + icon: str, + ) -> None: if not self.ctx.guild: return - daily_lb = Dailies.load_leaderboard() + daily_lb: list[tuple[int, int, str, int]] = 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: discord.Member | None = None + with contextlib.suppress(discord.HTTPException): member = await self.ctx.guild.fetch_member(user_id) - except discord.HTTPException: - member = None + name = member.name if member else str(user_id) - name = member.name if member else user_id - - claimed_at = datetime.fromisoformat(claimed_at).date() + claimed_at_date = datetime.fromisoformat(claimed_at).date() embed.add_field( name=f"#{rank} - {name}", value=CONST.STRINGS["xp_lb_dailies_field_value"].format( streak, - claimed_at, + claimed_at_date, ), inline=False, ) diff --git a/wrappers/__init__.py b/wrappers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wrappers/xkcd.py b/wrappers/xkcd.py index e7f5b30..b8cfc63 100644 --- a/wrappers/xkcd.py +++ b/wrappers/xkcd.py @@ -258,9 +258,7 @@ class Client: HttpError If the request fails. """ - comic_url = ( - self.latest_comic_url() if comic_id <= 0 else self.comic_id_url(comic_id) - ) + comic_url = self.latest_comic_url() if comic_id <= 0 else self.comic_id_url(comic_id) try: response = httpx.get(comic_url)