diff --git a/.gitignore b/.gitignore index 2cef6f8..fe02c38 100644 --- a/.gitignore +++ b/.gitignore @@ -184,7 +184,4 @@ config.toml database/table_insertions.sql database/migration_script.py misc/ -test_bot.py - -# Might be temporary. -core/utils/extras \ No newline at end of file +src/beira/utils/extras \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b8faa69..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM python:3.10-slim - -WORKDIR /app - -COPY requirements.txt requirements.txt -RUN pip3 install -r requirements.txt - -COPY . . - -CMD ["python3", "main.py"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e3a4bdd..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,61 +0,0 @@ -version: "3" - -networks: - main: - name: main - -services: - bot: - container_name: beira - networks: - - main - depends_on: - - database - - lavalink - restart: unless-stopped - volumes: - - ./config.json:/app/config.json - - database: - container_name: discord-beira-db - env_file: - - .env - environment: - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} - healthcheck: - interval: 1s - retries: 10 - test: - [ - "CMD-SHELL", - "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" - ] - timeout: 5s - image: postgres - restart: always - volumes: - - pgdata:/var/lib/postgresql/data - - ./database/schema.sql:/docker-entrypoint-initdb.d/schema.sql - - lavalink: - image: ghcr.io/lavalink-devs/lavalink:3 - container_name: lavalink - restart: unless-stopped - environment: - - _JAVA_OPTIONS=-Xmx6G # set Java options here - - SERVER_PORT=2333 # set lavalink server port - - LAVALINK_SERVER_PASSWORD=gloater-body-galvanize # set password for lavalink - volumes: - - ./application.yml:/opt/Lavalink/application.yml # mount application.yml from the same directory or use environment variables - - ./plugins/:/opt/Lavalink/plugins/ # persist plugins between restarts, make sure to set the correct permissions (user: 322, group: 322) - networks: - - main - expose: - - 2333 # lavalink exposes port 2333 to connect to for other containers (this is for documentation purposes only) - ports: - - 2333:2333 # you only need this if you want to make your lavalink accessible from outside of containers - -volumes: - pgdata: \ No newline at end of file diff --git a/src/beira/__init__.py b/src/beira/__init__.py index 121f3c5..9331184 100644 --- a/src/beira/__init__.py +++ b/src/beira/__init__.py @@ -1,4 +1,3 @@ from .bot import * from .checks import * -from .config import * from .errors import * diff --git a/src/beira/bot.py b/src/beira/bot.py index 758c6a8..23d192c 100644 --- a/src/beira/bot.py +++ b/src/beira/bot.py @@ -23,7 +23,7 @@ from .checks import is_blocked from .config import Config, load_config from .tree import HookableTree -from .utils import LoggingManager, Pool_alias, conn_init +from .utils import LoggingManager, Pool_alias, conn_init, copy_annotations LOGGER = logging.getLogger(__name__) @@ -40,12 +40,19 @@ class Context(commands.Context["Beira"]): Attributes ---------- + error_handled: bool, default=False + Whether an error handler has already taken care of an error. session db """ voice_client: wavelink.Player | None # type: ignore # Type lie for narrowing + @copy_annotations(commands.Context["Beira"].__init__) + def __init__(self, *args: object, **kwargs: object): + super().__init__(*args, **kwargs) + self.error_handled: bool = False + @property def session(self) -> aiohttp.ClientSession: """`ClientSession`: Returns the asynchronous HTTP session used by the bot for HTTP requests.""" @@ -193,12 +200,15 @@ async def on_error(self, event_method: str, /, *args: object, **kwargs: object) async def on_command_error(self, context: Context, exception: commands.CommandError) -> None: # type: ignore # Narrowing assert context.command # Pre-condition for being here. + if context.error_handled: + return + if isinstance(exception, commands.CommandNotFound): return exception = getattr(exception, "original", exception) - tb_text = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__, chain=False)) + tb_text = "".join(traceback.format_exception(exception, chain=False)) embed = ( discord.Embed( title="Command Error", diff --git a/src/beira/config.py b/src/beira/config.py index 33d266e..62d5b31 100644 --- a/src/beira/config.py +++ b/src/beira/config.py @@ -6,10 +6,7 @@ import msgspec -__all__ = ( - "Config", - "load_config", -) +__all__ = ("Config", "load_config") class Base(msgspec.Struct): diff --git a/src/beira/exts/_dev.py b/src/beira/exts/_dev.py index 6fc32ae..fbc4d8d 100644 --- a/src/beira/exts/_dev.py +++ b/src/beira/exts/_dev.py @@ -55,7 +55,7 @@ def __init__(self, bot: beira.Beira, dev_guilds: list[discord.Object]) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="discord_dev", animated=True, id=1084608963896672256) @@ -77,24 +77,6 @@ async def cog_check(self, ctx: beira.Context) -> bool: # type: ignore # Narrowi return await self.bot.is_owner(ctx.author) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - assert ctx.command - - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - embed = discord.Embed(color=0x5E9A40) - - if isinstance(error, commands.ExtensionError): - embed.description = f"Couldn't {ctx.command.name} extension: {error.name}\n{error}" - LOGGER.error("Couldn't %s extension: %s", ctx.command.name, error.name, exc_info=error) - else: - LOGGER.exception("", exc_info=error) - - await ctx.send(embed=embed, ephemeral=True) - @commands.hybrid_group(fallback="get") async def block(self, ctx: beira.Context) -> None: """A group of commands for blocking and unblocking users or guilds from using the bot. @@ -129,9 +111,9 @@ async def block_add( ---------- ctx: `beira.Context` The invocation context. - block_type: Literal["user", "guild"], default="user" + block_type: `Literal["user", "guild"]`, default="user" What type of entity or entities are being blocked. Defaults to "user". - entities: `commands.Greedy`[`discord.Object`] + entities: `commands.Greedy[discord.Object`] The entities to block. """ @@ -176,9 +158,9 @@ async def block_remove( ---------- ctx: `beira.Context` The invocation context - block_type: Literal["user", "guild"], default="user" + block_type: `Literal["user", "guild"]`, default="user" What type of entity or entities are being unblocked. Defaults to "user". - entities: `commands.Greedy`[`discord.Object`] + entities: `commands.Greedy[discord.Object`] The entities to unblock. """ @@ -212,24 +194,19 @@ async def block_remove( @block_add.error @block_remove.error async def block_change_error(self, ctx: beira.Context, error: commands.CommandError) -> None: + assert ctx.command + # Extract the original error. error = getattr(error, "original", error) if ctx.interaction: error = getattr(error, "original", error) - assert ctx.command - if isinstance(error, PostgresError | PostgresConnectionError): action = "block" if ctx.command.qualified_name == "block add" else "unblock" await ctx.send(f"Unable to {action} these users/guilds at this time.", ephemeral=True) - LOGGER.exception("", exc_info=error) @app_commands.check(lambda interaction: interaction.user.id == interaction.client.owner_id) - async def context_menu_block_add( - self, - interaction: beira.Interaction, - user: discord.User | discord.Member, - ) -> None: + async def context_menu_block_add(self, interaction: beira.Interaction, user: discord.User | discord.Member) -> None: stmt = """ INSERT INTO users (user_id, is_blocked) VALUES ($1, $2) @@ -274,13 +251,7 @@ async def shutdown(self, ctx: beira.Context) -> None: @commands.hybrid_command() async def walk(self, ctx: beira.Context) -> None: - """Walk through all app commands globally and in every guild to see what is synced and where. - - Parameters - ---------- - ctx: `beira.Context` - The invocation context where the command was called. - """ + """Walk through all app commands globally and in every guild to see what is synced and where.""" all_embeds: list[discord.Embed] = [] @@ -432,6 +403,24 @@ async def ext_autocomplete(self, _: beira.Interaction, current: str) -> list[app if current.lower() in ext.lower() ][:25] + @load.error + @unload.error + @reload.error + async def load_error(self, ctx: beira.Context, error: commands.CommandError) -> None: + assert ctx.command + + # Extract the original error. + error = getattr(error, "original", error) + if ctx.interaction: + error = getattr(error, "original", error) + + if isinstance(error, commands.ExtensionError): + embed = discord.Embed( + color=0x5E9A40, + description=f"Couldn't {ctx.command.name} extension: {error.name}\n{error}", + ) + await ctx.send(embed=embed, ephemeral=True) + @commands.hybrid_command("sync") @app_commands.choices(spec=[app_commands.Choice(name=name, value=value) for name, value in SPEC_CHOICES]) async def sync_( @@ -442,16 +431,16 @@ async def sync_( ) -> None: """Syncs the command tree in some way based on input. - The `spec` and `guilds` parameters are mutually exclusive. + ``spec`` and ``guilds`` are mutually exclusive. Parameters ---------- ctx: `beira.Context` The invocation context. - guilds: Greedy[`discord.Object`], optional + guilds: `Greedy[discord.Object`], optional The guilds to sync the app commands if no specification is entered. Converts guild ids to - `discord.Object`s. Please provide as IDs separated by spaces. - spec: Choice[`str`], optional + ``discord.Object``s. Please provide as IDs separated by spaces. + spec: `Choice[str]`, optional The type of sync to perform if no guilds are entered. No input means global sync. Notes @@ -461,19 +450,13 @@ async def sync_( Here is some elaboration on what the command would do with different arguments. Irrelevant with slash activation, but replace '$' with whatever your prefix is for prefix command activation: - `$sync`: Sync globally. - - `$sync ~`: Sync with current guild. - - `$sync *`: Copy all global app commands to current guild and sync. - - `$sync ^`: Clear all commands from the current guild target and sync, thereby removing guild commands. - - `$sync -`: (D-N-T!) Clear all global commands and sync, thereby removing all global commands. - - `$sync +`: (D-N-T!) Clear all commands from all guilds and sync, thereby removing all guild commands. - - `$sync ...`: Sync with those guilds of id_1, id_2, etc. + - `$sync`: Sync globally. + - `$sync ~`: Sync with current guild. + - `$sync *`: Copy all global app commands to current guild and sync. + - `$sync ^`: Clear all commands from the current guild target and sync, thereby removing guild commands. + - `$sync -`: (D-N-T!) Clear all global commands and sync, thereby removing all global commands. + - `$sync +`: (D-N-T!) Clear all commands from all guilds and sync, thereby removing all guild commands. + - `$sync ...`: Sync with those guilds of id_1, id_2, etc. References ---------- @@ -523,13 +506,13 @@ async def sync_( @sync_.error async def sync_error(self, ctx: beira.Context, error: commands.CommandError) -> None: - """A local error handler for the :meth:`sync_` command. + """A local error handler for the sync_ command. Parameters ---------- - ctx: `beira.Context` + ctx: beira.Context The invocation context. - error: `commands.CommandError` + error: commands.CommandError The error thrown by the command. """ @@ -592,16 +575,16 @@ def walk_commands_with_indent(group: commands.GroupMixin[Any]) -> Generator[str, async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" + dev_guild_ids = list(bot.config.discord.important_guilds["dev"]) + dev_guilds = [discord.Object(id=guild_id) for guild_id in bot.config.discord.important_guilds["dev"]] + cog = DevCog(bot, dev_guilds) # Can't use the guilds kwarg in add_cog, as it doesn't currently work for hybrids. # Ref: https://github.com/Rapptz/discord.py/pull/9428 - dev_guilds_objects = [discord.Object(id=guild_id) for guild_id in bot.config.discord.important_guilds["dev"]] - cog = DevCog(bot, dev_guilds_objects) - for cmd in cog.walk_app_commands(): - if cmd._guild_ids is None: - cmd._guild_ids = [g.id for g in dev_guilds_objects] + for cmd in cog.get_app_commands(): + if cmd._guild_ids is None: # pyright: ignore [reportPrivateUsage] + cmd._guild_ids = dev_guild_ids # pyright: ignore [reportPrivateUsage] else: - cmd._guild_ids.extend(g.id for g in dev_guilds_objects) + cmd._guild_ids.extend(dev_guild_ids) # pyright: ignore [reportPrivateUsage] await bot.add_cog(cog) diff --git a/src/beira/exts/_test.py b/src/beira/exts/_test.py index 4d87b04..b9d1886 100644 --- a/src/beira/exts/_test.py +++ b/src/beira/exts/_test.py @@ -30,14 +30,6 @@ async def cog_check(self, ctx: beira.Context) -> bool: # type: ignore # Narrowi return await self.bot.is_owner(ctx.author) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.command() async def test_pre(self, ctx: beira.Context) -> None: """Test prefix command.""" @@ -92,16 +84,15 @@ async def test_hooks(self, itx: discord.Interaction, arg: str) -> None: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" + dev_guild_ids = list(bot.config.discord.important_guilds["dev"]) + cog = TestCog(bot) # Can't use the guilds kwarg in add_cog, as it doesn't currently work for hybrids. # Ref: https://github.com/Rapptz/discord.py/pull/9428 - dev_guilds_objects = [discord.Object(id=guild_id) for guild_id in bot.config.discord.important_guilds["dev"]] - cog = TestCog(bot) - for cmd in cog.walk_app_commands(): - if cmd._guild_ids is None: - cmd._guild_ids = [g.id for g in dev_guilds_objects] + for cmd in cog.get_app_commands(): + if cmd._guild_ids is None: # pyright: ignore [reportPrivateUsage] + cmd._guild_ids = dev_guild_ids # pyright: ignore [reportPrivateUsage] else: - cmd._guild_ids.extend(g.id for g in dev_guilds_objects) + cmd._guild_ids.extend(dev_guild_ids) # pyright: ignore [reportPrivateUsage] await bot.add_cog(cog) diff --git a/src/beira/exts/admin.py b/src/beira/exts/admin.py index 4df9244..f7715fe 100644 --- a/src/beira/exts/admin.py +++ b/src/beira/exts/admin.py @@ -1,5 +1,5 @@ -"""admin.py: A cog that implements commands for reloading and syncing extensions and other commands, at a guild owner -or bot owner's behest. +"""A cog that implements commands for reloading and syncing extensions and other commands, at a guild owner or bot +owner's behest. """ import logging @@ -22,18 +22,10 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="endless_gears", animated=True, id=1077981366911766549) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.hybrid_command() @commands.guild_only() async def get_timeouts(self, ctx: beira.GuildContext) -> None: @@ -124,13 +116,7 @@ async def prefixes_remove(self, ctx: beira.GuildContext, *, old_prefix: str) -> @commands.guild_only() @commands.check_any(commands.is_owner(), beira.is_admin()) async def prefixes_reset(self, ctx: beira.GuildContext) -> None: - """Remove all prefixes within this server for the bot to respond to. - - Parameters - ---------- - ctx: `beira.GuildContext` - The invocation context. - """ + """Remove all prefixes within this server for the bot to respond to.""" async with ctx.typing(): # Update it in the database and the cache. @@ -145,26 +131,24 @@ async def prefixes_reset(self, ctx: beira.GuildContext) -> None: @prefixes_remove.error @prefixes_reset.error async def prefixes_subcommands_error(self, ctx: beira.Context, error: commands.CommandError) -> None: + assert ctx.command + # Extract the original error. error = getattr(error, "original", error) if ctx.interaction: error = getattr(error, "original", error) - assert ctx.command if isinstance(error, PostgresWarning | PostgresError): if ctx.command.name == "add": await ctx.send("This prefix could not be added at this time.") + ctx.error_handled = True elif ctx.command.name == "remove": await ctx.send("This prefix could not be removed at this time.") + ctx.error_handled = True elif ctx.command.name == "reset": await ctx.send("This server's prefixes could not be reset.") - else: - LOGGER.exception("", exc_info=error) - else: - LOGGER.exception("", exc_info=error) + ctx.error_handled = True async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(AdminCog(bot)) diff --git a/src/beira/exts/bot_stats.py b/src/beira/exts/bot_stats.py index 5167b1b..968d555 100644 --- a/src/beira/exts/bot_stats.py +++ b/src/beira/exts/bot_stats.py @@ -1,4 +1,4 @@ -"""bot_stats.py: A cog for tracking different bot metrics.""" +"""A cog for tracking different bot metrics.""" import logging from datetime import timedelta @@ -42,18 +42,10 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{CHART WITH UPWARDS TREND}") - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - async def track_command_use(self, ctx: beira.Context) -> None: """Stores records of command uses in the database after some processing.""" @@ -134,6 +126,57 @@ async def add_guild_to_db(self, guild: discord.Guild) -> None: stmt = "INSERT INTO guilds (guild_id) VALUES ($1) ON CONFLICT (guild_id) DO NOTHING;" await self.bot.db_pool.execute(stmt, guild.id, timeout=60.0) + async def get_usage( + self, + time_period: int = 0, + command: str | None = None, + guild: discord.Guild | None = None, + universal: bool = False, + ) -> list[asyncpg.Record]: + """Queries the database for command usage.""" + + query_args: list[object] = [] # Holds the query args as objects. + where_params: list[str] = [] # Holds the query param placeholders as formatted strings. + + # Create the base queries. + if guild: + query = """\ + SELECT u.user_id, COUNT(*) + FROM commands cmds INNER JOIN users u on cmds.user_id = u.user_id + {where} + GROUP BY u.user_id + ORDER BY COUNT(*) DESC + LIMIT 10; + """ + + else: + query = """\ + SELECT g.guild_id, COUNT(*) + FROM commands cmds INNER JOIN guilds g on cmds.guild_id = g.guild_id + {where} + GROUP BY g.guild_id + ORDER BY COUNT(*) DESC + LIMIT 10; + """ + + # Create the WHERE clause for the query. + if guild and not universal: + query_args.append(guild.id) + where_params.append(f"guild_id = ${len(query_args)}") + + if time_period and (time_period > 0): + query_args.append(discord.utils.utcnow() - timedelta(days=time_period)) + where_params.append(f"date_time >= ${len(query_args)}") + + if command: + query_args.append(command) + where_params.append(f"command = ${len(query_args)}") + + # Add the WHERE clause to the query if necessary. + where_clause = f"WHERE {' AND '.join(where_params)}\n" if len(query_args) > 0 else "" + query = query.format(where=where_clause) + return await self.bot.db_pool.fetch(query, *query_args) + @commands.hybrid_command(name="usage") async def check_usage(self, ctx: beira.Context, *, search_factors: CommandStatsSearchFlags) -> None: """Retrieve statistics about bot command usage. @@ -175,57 +218,6 @@ async def check_usage(self, ctx: beira.Context, *, search_factors: CommandStatsS await ctx.reply(embed=embed) - async def get_usage( - self, - time_period: int = 0, - command: str | None = None, - guild: discord.Guild | None = None, - universal: bool = False, - ) -> list[asyncpg.Record]: - """Queries the database for command usage.""" - - query_args: list[object] = [] # Holds the query args as objects. - where_params: list[str] = [] # Holds the query param placeholders as formatted strings. - - # Create the base queries. - if guild: - query = """\ -SELECT u.user_id, COUNT(*) -FROM commands cmds INNER JOIN users u on cmds.user_id = u.user_id -{where} -GROUP BY u.user_id -ORDER BY COUNT(*) DESC -LIMIT 10; -""" - - else: - query = """\ -SELECT g.guild_id, COUNT(*) -FROM commands cmds INNER JOIN guilds g on cmds.guild_id = g.guild_id -{where} -GROUP BY g.guild_id -ORDER BY COUNT(*) DESC -LIMIT 10; -""" - - # Create the WHERE clause for the query. - if guild and not universal: - query_args.append(guild.id) - where_params.append(f"guild_id = ${len(query_args)}") - - if time_period and (time_period > 0): - query_args.append(discord.utils.utcnow() - timedelta(days=time_period)) - where_params.append(f"date_time >= ${len(query_args)}") - - if command: - query_args.append(command) - where_params.append(f"command = ${len(query_args)}") - - # Add the WHERE clause to the query if necessary. - where_clause = f"WHERE {' AND '.join(where_params)}\n" if len(query_args) > 0 else "" - query = query.format(where=where_clause) - return await self.bot.db_pool.fetch(query, *query_args) - @check_usage.autocomplete("command") async def command_autocomplete(self, interaction: beira.Interaction, current: str) -> list[Choice[str]]: """Autocompletes with bot command names.""" @@ -244,6 +236,4 @@ async def command_autocomplete(self, interaction: beira.Interaction, current: st async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(BotStatsCog(bot)) diff --git a/src/beira/exts/dice.py b/src/beira/exts/dice.py index a7ae9bf..4342016 100644 --- a/src/beira/exts/dice.py +++ b/src/beira/exts/dice.py @@ -1,4 +1,4 @@ -"""dice.py: The extension that holds a die roll command and all the associated utility classes.""" +"""The extension that holds a die roll command and all the associated utility classes.""" # TODO: Consider adding more elements from https://wiki.roll20.net/Dice_Reference. import logging @@ -27,11 +27,11 @@ class Die(msgspec.Struct, frozen=True): Attributes ---------- - value: `int` + value: int The highest number the die can roll. - emoji: `discord.PartialEmoji` + emoji: discord.PartialEmoji The emoji representing the die in displays. - color: `discord.Colour` + color: discord.Colour The color representing the die in embed displays. """ @@ -41,7 +41,7 @@ class Die(msgspec.Struct, frozen=True): @property def label(self) -> str: - """`str`: The label, or name, of the die. Defaults to ``D{value}``, as with most dice in casual discussion.""" + """str: The label, or name, of the die. Defaults to ``D{value}``, as with most dice in casual discussion.""" return f"D{self.value}" @@ -65,12 +65,12 @@ def roll_basic_dice(dice_info: dict[int, int]) -> dict[int, list[int]]: Parameters ---------- - dice_info: dict[`int`, `int`] + dice_info: dict[int, int] A mapping from the maximum value of a particular die to the number of times to roll that die. Returns ------- - rolls_info: dict[`int`, list[`int`]] + rolls_info: dict[int, list[int]] A mapping from the maximum value of a particular die to the list of rolls that it made. """ @@ -85,12 +85,12 @@ def roll_custom_dice_expression(expression: str) -> tuple[str, int]: Parameters ---------- - expression: `str` + expression: str The expression to roll. Returns ------- - normalized_expression, evaluation: tuple[`str`, `int`] + normalized_expression, evaluation: tuple[str, int] A tuple with filled in expression and final result. Notes @@ -157,11 +157,11 @@ class DiceEmbed(discord.Embed): Parameters ---------- - rolls_info: dict[`int`, list[`int`]], optional + rolls_info: dict[int, list[int]], optional A dictionary of information with dice types as keys and corresponding dice rolls as values. - modifier: `int`, default=0 + modifier: int, default=0 The post-calculation modifier for the dice rolls. - expression_info: tuple[`str`, `str`, `str`], optional + expression_info: tuple[str, str, str], optional A tuple with the original expression, the expression filled with dice rolls, and the expressions' final result. """ @@ -286,14 +286,14 @@ class DiceButton(ui.Button["DiceView"]): Parameters ---------- - die: `Die` + die: Die The dataclass for the die being represented by the button. Attributes ---------- - response_colour: `discord.Colour` + response_colour: discord.Colour The color representing a die in embed displays. - value: `int` + value: int The max possible roll for a die. """ @@ -360,9 +360,9 @@ class DiceModifierModal(ui.Modal): Attributes ---------- - modifier_input: `TextInput` + modifier_input: TextInput The text box with which users will enter their numerical values. - interaction: `discord.Interaction` + interaction: discord.Interaction The user interaction, to be used by other classes to ensure continuity in the view interaction flow. """ @@ -389,9 +389,9 @@ class DiceExpressionModal(ui.Modal): Attributes ---------- - expression_input: `TextInput` + expression_input: TextInput The text box with which users will enter their expression. - interaction: `discord.Interaction` + interaction: discord.Interaction The user interaction, to be used by other classes to ensure continuity in the view interaction flow. """ @@ -418,11 +418,11 @@ class DiceView(ui.View): Attributes ---------- - modifier: `int` + modifier: int The modifier to apply at the end of a roll or series of rolls. - num_rolls: `int` + num_rolls: int The number of rolls to perform. Allows item interactions to cause multiple rolls. - expression: `str` + expression: str The custom dice expression from user input to be evaluated. """ @@ -620,16 +620,6 @@ async def roll(ctx: beira.Context, expression: str | None = None) -> None: await ctx.send(embed=embed, view=view) -@roll.error # pyright: ignore [reportUnknownMemberType] # discord.py bug: see https://github.com/Rapptz/discord.py/issues/9788. -async def roll_error(ctx: beira.Context, error: commands.CommandError) -> None: - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - - async def setup(bot: beira.Beira) -> None: """Add roll command and persistent dice view to bot.""" diff --git a/src/beira/exts/emoji_ops.py b/src/beira/exts/emoji_ops.py index 6803927..5e835bb 100644 --- a/src/beira/exts/emoji_ops.py +++ b/src/beira/exts/emoji_ops.py @@ -1,4 +1,4 @@ -"""emoji_ops.py: This cog is meant to provide functionality for stealing emojis. +"""This cog is meant to provide functionality for stealing emojis. Credit to Froopy and Danny for inspiration from their bots. """ @@ -37,15 +37,15 @@ class GuildStickerFlags(commands.FlagConverter): Attributes ---------- - name: `str`, optional + name: str, optional The name of the sticker. Must be at least 2 characters. - description: `str`, optional + description: str, optional The description for the sticker. - emoji: `str`, optional + emoji: str, optional The name of a unicode emoji that represents the sticker's expression. - attachment: `discord.Attachment`, optional + attachment: discord.Attachment, optional An image attachment. Must be a PNG or APNG less than 512Kb and exactly 320x320 px to work. - reason: `str`, optional + reason: str, optional The reason for the sticker's existence to put in the audit log. Not needed usually. """ @@ -72,16 +72,16 @@ class AddEmojiButton(discord.ui.Button["AddEmojisView"]): Parameters ---------- - guild: `discord.Guild` + guild: discord.Guild The guild that an emoji could be added to. - emoji: `discord.PartialEmoji` + emoji: discord.PartialEmoji The emoji that this button will display and that could be added to the attached guild. Attributes ---------- - guild: `discord.Guild` + guild: discord.Guild The guild that the button emoji could be or was added to. - new_emoji: `discord.Emoji` | None. + new_emoji: discord.Emoji | None The emoji that was successfully generated by being added to a guild. If None, it was either deleted or not added yet. """ @@ -116,9 +116,9 @@ class AddEmojisView(discord.ui.View): Parameters ---------- - guild: `discord.Guild` + guild: discord.Guild The guild that emojis could be added to. - emojis: list[`discord.PartialEmoji`] + emojis: list[discord.PartialEmoji] The emojis that the buttons in this view will display and can add/delete to the attached guild. """ @@ -233,7 +233,7 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{GRINNING FACE}") @@ -246,9 +246,9 @@ async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: Parameters ---------- - ctx: `commands.Context` + ctx: commands.Context The invocation context - error: `Exception` + error: Exception The error thrown by the command. """ @@ -264,13 +264,13 @@ async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # Respond to the error. if isinstance(error, discord.Forbidden): embed.description = "You aren't allowed to create emojis/stickers here." + ctx.error_handled = True elif isinstance(error, discord.HTTPException): embed.description = "Something went wrong in the creation process." - LOGGER.exception("", exc_info=error) elif isinstance(error, commands.GuildStickerNotFound): embed.description = "That is not a valid sticker name or ID, sorry!" + ctx.error_handled = True else: - LOGGER.exception("Error in `%s` command", ctx.command.name, exc_info=error) embed.description = "Something went wrong. The emoji/sticker could not be added." await ctx.send(embed=embed) @@ -281,14 +281,14 @@ async def convert_str_to_emoji(ctx: beira.Context, entity: str) -> discord.Emoji Parameters ---------- - ctx: `beira.Context` + ctx: beira.Context The invocation context. - entity: `str` + entity: str The string that might be an emoji or unicode character. Returns ------- - converted_emoji: `discord.Emoji` | `discord.PartialEmoji` | None + converted_emoji: discord.Emoji | discord.PartialEmoji | None The converted emoji or ``None`` if conversion failed. """ @@ -385,7 +385,7 @@ async def emoji_add( The invocation context. name: `str` The name of the emoji. - entity: `str` | None, optional + entity: `str | None`, optional An emoji or url. attachment: `discord.Attachment`, optional An image attachment. Must be a PNG, JPG, or GIF to work. @@ -442,8 +442,10 @@ async def sticker_info(self, ctx: beira.GuildContext, sticker: str, *, ephemeral ---------- ctx: `beira.GuildContext` The invocation context. - sticker: `discord.GuildSticker` + sticker: `str` The id or name of the sticker to provide information about. + ephemeral: `bool`, default=True + Whether the resulting info embed should be visible to everyone. Defaults to True. """ try: @@ -495,7 +497,7 @@ async def sticker_add( ---------- ctx: `beira.GuildContext` The invocation context. - sticker: `discord.GuildSticker`, optional + sticker: `str`, optional The name or id of an existing sticker to steal. If filled, no other parameters are necessary. sticker_flags: `GuildStickerFlags` Flags for a sticker's payload. @@ -532,6 +534,4 @@ async def sticker_add( async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(EmojiOpsCog(bot)) diff --git a/src/beira/exts/fandom_wiki.py b/src/beira/exts/fandom_wiki.py index 7f92f67..cd3f09a 100644 --- a/src/beira/exts/fandom_wiki.py +++ b/src/beira/exts/fandom_wiki.py @@ -1,6 +1,4 @@ -"""fandom_wiki.py: A cog for searching a fandom's Fandom wiki page. Starting with characters from the ACI100 wiki -first. -""" +"""A cog for searching a fandom's Fandom wiki page. Starting with characters from the ACI100 wiki first.""" import asyncio import logging @@ -26,20 +24,21 @@ "Team StarKid": "https://starkid.fandom.com", } -AOC_EMOJI_URL, JARE_EMOJI_URL = EMOJI_URL.format(770620658501025812), EMOJI_URL.format(1061029880059400262) +AOC_EMOJI_URL = EMOJI_URL.format(770620658501025812) +JARE_EMOJI_URL = EMOJI_URL.format(1061029880059400262) class AoCWikiEmbed(discord.Embed): - """A subclass of `discord.Embed` that is set up for representing Ashes of Chaos wiki pages. + """A subclass of discord.Embed that is set up for representing Ashes of Chaos wiki pages. Parameters ---------- - author_icon_url: `str`, optional + author_icon_url: str, optional The image url for the embed's author icon. Defaults to the AoC emoji url. - footer_icon_url: `str`, optional + footer_icon_url: str, optional The image url for the embed's footer icon. Defaults to the Mr. Jare emoji url. **kwargs - Keyword arguments for the normal initialization of an `DTEmbed`. + Keyword arguments for the normal initialization of discord.Embed. """ aoc_wiki_url = "https://ashes-of-chaos.fandom.com" @@ -173,27 +172,15 @@ class FandomWikiSearchCog(commands.Cog, name="Fandom Wiki Search"): """A cog for searching a fandom's Fandom wiki page. This can only handle characters from the ACI100 Ashes of Chaos wiki right now. - - Parameters - ---------- - bot: `beira.Beira` - The main Discord bot this cog is a part of. - - Attributes - ---------- - bot: `beira.Beira` - The main Discord bot this cog is a part of. - all_wikis: dict[`str`, dict[`str`, `str`]] - The dict containing information for various wikis. """ def __init__(self, bot: beira.Beira) -> None: self.bot = bot - self.all_wikis: dict[str, dict[str, str]] = {} + self.all_wikis: dict[str, dict[str, str]] = {} # A dict containing info for various wikis. @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="fandom", id=1077980392742727791) @@ -206,14 +193,6 @@ async def cog_load(self) -> None: LOGGER.info("All wiki names: %s", list(self.all_wikis.keys())) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.hybrid_command() @commands.cooldown(1, 5, commands.cooldowns.BucketType.user) async def wiki(self, ctx: beira.Context, wiki: str, search_term: str) -> None: @@ -330,6 +309,4 @@ async def search_wiki(self, wiki_name: str, wiki_query: str) -> discord.Embed: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(FandomWikiSearchCog(bot)) diff --git a/src/beira/exts/ff_metadata/__init__.py b/src/beira/exts/ff_metadata/__init__.py index 12441ba..77459fc 100644 --- a/src/beira/exts/ff_metadata/__init__.py +++ b/src/beira/exts/ff_metadata/__init__.py @@ -4,6 +4,4 @@ async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(FFMetadataCog(bot)) diff --git a/src/beira/exts/ff_metadata/ff_metadata.py b/src/beira/exts/ff_metadata/ff_metadata.py index 2921cdb..61d59eb 100644 --- a/src/beira/exts/ff_metadata/ff_metadata.py +++ b/src/beira/exts/ff_metadata/ff_metadata.py @@ -1,4 +1,4 @@ -"""ff_metadata.py: A cog with triggers for retrieving story metadata.""" +"""A cog with triggers for retrieving story metadata.""" # TODO: Account for orphaned fics, anonymous fics, really long embed descriptions, and series with more than 25 fics. import logging @@ -34,6 +34,7 @@ class FFMetadataCog(commands.GroupCog, name="Fanfiction Metadata Search", group_ def __init__(self, bot: beira.Beira) -> None: self.bot = bot + self.atlas_client = bot.atlas_client self.fichub_client = bot.fichub_client self.ao3_client = bot.ao3_client @@ -53,14 +54,6 @@ async def cog_load(self) -> None: for record in records: self.allowed_channels_cache.setdefault(record["guild_id"], set()).add(record["channel_id"]) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.Cog.listener("on_message") async def on_posted_fanfic_link(self, message: discord.Message) -> None: """Send informational embeds about a story if the user sends a fanfiction link. @@ -133,7 +126,7 @@ async def autoresponse_add( ---------- ctx: `beira.GuildContext` The invocation context. - channels: `commands.Greedy`[`discord.abc.GuildChannel`] + channels: `commands.Greedy[discord.abc.GuildChannel]` A list of channels to add, separated by spaces. """ @@ -173,7 +166,7 @@ async def autoresponse_remove( ---------- ctx: `beira.GuildContext` The invocation context. - channels: `commands.Greedy`[`discord.abc.GuildChannel`] + channels: `commands.Greedy[discord.abc.GuildChannel]` A list of channels to remove, separated by spaces. """ @@ -212,7 +205,7 @@ async def ff_search( ---------- ctx: `beira.Context` The invocation context. - platform: Literal["ao3", "ffn", "other"] + platform: `Literal["ao3", "ffn", "other"]` The platform to search. name_or_url: `str` The search string for the story title, or the story url. diff --git a/src/beira/exts/ff_metadata/utils.py b/src/beira/exts/ff_metadata/utils.py index bdedfd9..c4fa2c1 100644 --- a/src/beira/exts/ff_metadata/utils.py +++ b/src/beira/exts/ff_metadata/utils.py @@ -63,10 +63,7 @@ class StoryWebsite(NamedTuple): def create_ao3_work_embed(work: ao3.Work) -> discord.Embed: - """Create an embed that holds all the relevant metadata for an Archive of Our Own work. - - Only accepts `ao3.Work` objects. - """ + """Create an embed that holds all the relevant metadata for an Archive of Our Own work.""" # Format the relevant information. if work.date_updated: @@ -106,10 +103,7 @@ def create_ao3_work_embed(work: ao3.Work) -> discord.Embed: def create_ao3_series_embed(series: ao3.Series) -> discord.Embed: - """Create an embed that holds all the relevant metadata for an Archive of Our Own series. - - Only accepts `ao3.Series` objects. - """ + """Create an embed that holds all the relevant metadata for an Archive of Our Own series.""" author_url = f"https://archiveofourown.org/users/{series.creators[0].name}" @@ -138,10 +132,7 @@ def create_ao3_series_embed(series: ao3.Series) -> discord.Embed: def create_atlas_ffn_embed(story: atlas_api.Story) -> discord.Embed: - """Create an embed that holds all the relevant metadata for a FanFiction.Net story. - - Only accepts `atlas_api.Story` objects from my own Atlas wrapper. - """ + """Create an embed that holds all the relevant metadata for a FanFiction.Net story.""" # Format the relevant information. update_date = story.updated if story.updated else story.published @@ -170,10 +161,7 @@ def create_atlas_ffn_embed(story: atlas_api.Story) -> discord.Embed: def create_fichub_embed(story: fichub_api.Story) -> discord.Embed: - """Create an embed that holds all the relevant metadata for a few different types of online fiction story. - - Only accepts `fichub_api.Story` objects from my own FicHub wrapper. - """ + """Create an embed that holds all the relevant metadata for a few different types of online fiction story.""" # Format the relevant information. updated = story.updated.strftime("%B %d, %Y") @@ -218,19 +206,17 @@ def create_fichub_embed(story: fichub_api.Story) -> discord.Embed: def ff_embed_factory(story_data: Any | None) -> discord.Embed | None: - if story_data is None: - return None - - if isinstance(story_data, atlas_api.Story): - return create_atlas_ffn_embed(story_data) - if isinstance(story_data, fichub_api.Story): - return create_fichub_embed(story_data) - if isinstance(story_data, ao3.Work): - return create_ao3_work_embed(story_data) - if isinstance(story_data, ao3.Series): - return create_ao3_series_embed(story_data) - - return None + match story_data: + case atlas_api.Story(): + return create_atlas_ffn_embed(story_data) + case fichub_api.AO3Story() | fichub_api.FFNStory() | fichub_api.OtherStory(): + return create_fichub_embed(story_data) + case ao3.Work(): + return create_ao3_work_embed(story_data) + case ao3.Series(): + return create_ao3_series_embed(story_data) + case _: + return None class AO3SeriesView(PaginatedSelectView[ao3.Work]): @@ -238,17 +224,17 @@ class AO3SeriesView(PaginatedSelectView[ao3.Work]): Parameters ---------- - author_id: `int` + author_id: int The Discord ID of the user that triggered this view. No one else can use it. - series: `ao3.Series` + series: ao3.Series The object holding metadata about an AO3 series and the works within. - timeout: `float` | None, optional + timeout: float | None, optional Timeout in seconds from last interaction with the UI before no longer accepting input. If ``None`` then there is no timeout. Attributes ---------- - series: `ao3.Series` + series: ao3.Series The object holding metadata about an AO3 series and the works within. """ diff --git a/src/beira/exts/help.py b/src/beira/exts/help.py index b088af9..4e141ee 100644 --- a/src/beira/exts/help.py +++ b/src/beira/exts/help.py @@ -1,8 +1,6 @@ -"""help.py: A custom help command for Beira set through a cog. +"""A custom help command for Beira set through a cog. -Notes ------ -The guide this was based off of: https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96 +The implementation is based off of this guide: https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96 """ import logging @@ -24,9 +22,9 @@ class HelpBotView(PaginatedEmbedView[tuple[str, tuple[tuple[str, str], ...]]]): - """A subclass of `PaginatedEmbedView` that handles paginated embeds, specifically for help commands. + """A subclass of PaginatedEmbedView that handles paginated embeds, specifically for help commands. - This is for a call to `/help`. + This is for a call to /help. """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -71,9 +69,9 @@ def validate_page_entry(self, value: str) -> int | None: class HelpCogView(PaginatedEmbedView[tuple[str, str]]): - """A subclass of `PaginatedEmbedView` that handles paginated embeds, specifically for help commands. + """A subclass of PaginatedEmbedView that handles paginated embeds, specifically for help commands. - This is for a call to `/help `. + This is for a call to /help . """ def __init__(self, *args: Any, cog_info: tuple[str, str], **kwargs: Any) -> None: @@ -189,7 +187,7 @@ async def send_error_message(self, error: str, /) -> None: def get_opening_note(self) -> str: """Returns help command's opening note. - Implementation borrowed from `commands.MinimalHelpCommand`. + Implementation borrowed from commands.MinimalHelpCommand. """ command_name = self.invoked_with @@ -201,7 +199,7 @@ def get_opening_note(self) -> str: def get_command_signature(self, command: commands.Command[Any, ..., Any], /) -> str: """Returns formatted command signature. - Implementation borrowed from `commands.MinimalHelpCommand`. + Implementation borrowed from commands.MinimalHelpCommand. """ return f"{self.context.clean_prefix}{command.qualified_name} {command.signature}" @@ -217,7 +215,7 @@ def clean_docstring(docstring: str) -> str: class HelpCog(commands.Cog, name="Help"): - """A cog that allows more dynamic usage of my custom help command class, `BeiraHelpCommand`.""" + """A cog that allows more dynamic usage of a custom help command class.""" def __init__(self, bot: beira.Beira) -> None: self.bot = bot @@ -230,13 +228,17 @@ async def cog_unload(self) -> None: self.bot.help_command = self._old_help_command - async def cog_command_error(self, ctx: commands.Context[Any], error: Exception) -> None: - error = getattr(error, "original", error) - LOGGER.exception("", exc_info=error) - @app_commands.command(name="help") async def help_(self, interaction: beira.Interaction, command: str | None = None) -> None: - """Access the help commands through the slash system.""" + """Access the help commands through the slash system. + + Parameters + ---------- + interaction: `beira.Interaction` + The command interaction. + command: `str`, optional + A name to match to a bot command. If unfilled, default to the generic help dialog. + """ ctx = await self.bot.get_context(interaction, cls=beira.Context) diff --git a/src/beira/exts/lol.py b/src/beira/exts/lol.py index 1a36b7d..88cf925 100644 --- a/src/beira/exts/lol.py +++ b/src/beira/exts/lol.py @@ -1,4 +1,4 @@ -"""lol.py: A cog for checking user win rates and other stats in League of Legends. +"""A cog for checking user win rates and other stats in League of Legends. Credit to Ralph for the idea and initial implementation. """ @@ -33,7 +33,7 @@ async def update_op_gg_profiles(urls: list[str]) -> None: Parameters ---------- - urls: list[`str`] + urls: list[str] The op.gg profile urls to interact with during this webdriver session. """ @@ -96,10 +96,7 @@ async def update(self, interaction: beira.Interaction, button: discord.ui.Button class LoLCog(commands.Cog, name="League of Legends"): - """A cog for checking user win rates and ranks in League of Legends. - - Credit to Ralph for the main code; I'm just testing it out to see how it would work in Discord. - """ + """A cog for checking user win rates and ranks in League of Legends.""" def __init__(self, bot: beira.Beira) -> None: self.bot = bot @@ -123,17 +120,79 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="ok_lol", id=1077980829315252325) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) + async def check_lol_stats(self, summoner_name: str) -> tuple[str, str, str]: + """Queries the OP.GG website for a summoner's winrate and rank. + + Parameters + ---------- + summoner_name: str + The name of the League of Legends player. + + Returns + ------- + summoner_name, winrate, rank: tuple[str, str, str] + The stats of the LoL user, including name, winrate, and rank. + """ + + adjusted_name = quote(summoner_name) + url = urljoin(self.req_site, adjusted_name) + + try: + async with self.bot.web_session.get(url, headers=self.req_headers) as response: + content = await response.text() - LOGGER.exception("", exc_info=error) + # Parse the summoner information for winrate and tier (referred to later as rank). + tree = lxml.html.fromstring(content) + winrate = str(tree.xpath("//div[@class='ratio']/string()")).removeprefix("Win Rate") + rank = str(tree.xpath("//div[@class='tier']/string()")).capitalize() + if not (winrate and rank): + winrate, rank = "None", "None" + except (AttributeError, aiohttp.ClientError): + # Thrown if the summoner has no games in ranked or no data at all. + winrate, rank = "None", "None" + + await asyncio.sleep(0.25) + + return summoner_name, winrate, rank + + async def create_lol_leaderboard(self, summoner_name_list: list[str]) -> StatsEmbed: + """Asynchronously performs queries to OP.GG for summoners' stats and displays them as a leaderboard. + + Parameters + ---------- + summoner_name_list: list[str] + The list of summoner names that will be queried via OP.GG for League of Legends stats, e.g. winrate/rank. + + Returns + ------- + embed: StatsEmbed + The Discord embed with leaderboard fields for all ranked summoners. + """ + + # Get the information for every user. + tasks = [self.bot.loop.create_task(self.check_lol_stats(name)) for name in summoner_name_list] + results = await asyncio.gather(*tasks) + + leaderboard = [result for result in results if result[1:2] != ("None", "None")] + leaderboard.sort(key=lambda x: x[1]) + + # Construct the embed for the leaderboard. + embed = StatsEmbed( + color=0x193D2C, + title="League of Legends Leaderboard", + description=( + "If players are missing, they either don't exist or aren't ranked.\n" + r"(Winrate \|| Rank)" + "\n―――――――――――" + ), + ) + if leaderboard: + embed.add_leaderboard_fields(ldbd_content=leaderboard, ldbd_emojis=[":medal:"], value_format=r"({} \|| {})") + return embed @commands.hybrid_group() async def lol(self, ctx: beira.Context) -> None: @@ -175,7 +234,7 @@ async def lol_leaderboard(self, ctx: beira.Context, *, summoner_names: str | Non ---------- ctx: `beira.Context` The invocation context. - summoner_names: list[`str`] + summoner_names: `list[str]` A string of summoner names to create a leaderboard from. Separate these by spaces. """ @@ -196,78 +255,6 @@ async def lol_leaderboard(self, ctx: beira.Context, *, summoner_names: str | Non await ctx.send(embed=embed, view=view) - async def create_lol_leaderboard(self, summoner_name_list: list[str]) -> StatsEmbed: - """Asynchronously performs queries to OP.GG for summoners' stats and displays them as a leaderboard. - - Parameters - ---------- - summoner_name_list: list['str'] - The list of summoner names that will be queried via OP.GG for League of Legends stats, e.g. winrate/rank. - - Returns - ------- - embed: `StatsEmbed` - The Discord embed with leaderboard fields for all ranked summoners. - """ - - # Get the information for every user. - tasks = [self.bot.loop.create_task(self.check_lol_stats(name)) for name in summoner_name_list] - results = await asyncio.gather(*tasks) - - leaderboard = [result for result in results if result[1:2] != ("None", "None")] - leaderboard.sort(key=lambda x: x[1]) - - # Construct the embed for the leaderboard. - embed = StatsEmbed( - color=0x193D2C, - title="League of Legends Leaderboard", - description=( - "If players are missing, they either don't exist or aren't ranked.\n" - r"(Winrate \|| Rank)" - "\n―――――――――――" - ), - ) - if leaderboard: - embed.add_leaderboard_fields(ldbd_content=leaderboard, ldbd_emojis=[":medal:"], value_format=r"({} \|| {})") - return embed - - async def check_lol_stats(self, summoner_name: str) -> tuple[str, str, str]: - """Queries the OP.GG website for a summoner's winrate and rank. - - Parameters - ---------- - summoner_name: `str` - The name of the League of Legends player. - - Returns - ------- - summoner_name, winrate, rank: tuple[`str`, `str`, `str`] - The stats of the LoL user, including name, winrate, and rank. - """ - - adjusted_name = quote(summoner_name) - url = urljoin(self.req_site, adjusted_name) - - try: - async with self.bot.web_session.get(url, headers=self.req_headers) as response: - content = await response.text() - - # Parse the summoner information for winrate and tier (referred to later as rank). - tree = lxml.html.fromstring(content) - winrate = str(tree.xpath("//div[@class='ratio']/string()")).removeprefix("Win Rate") - rank = str(tree.xpath("//div[@class='tier']/string()")).capitalize() - if not (winrate and rank): - winrate, rank = "None", "None" - except (AttributeError, aiohttp.ClientError): - # Thrown if the summoner has no games in ranked or no data at all. - winrate, rank = "None", "None" - - await asyncio.sleep(0.25) - - return summoner_name, winrate, rank - async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(LoLCog(bot)) diff --git a/src/beira/exts/music.py b/src/beira/exts/music.py index c2c3bdc..71f0575 100644 --- a/src/beira/exts/music.py +++ b/src/beira/exts/music.py @@ -1,9 +1,8 @@ -"""music.py: This cog provides functionality for playing tracks in voice channels given search terms or urls, -implemented with Wavelink. +"""This cog provides functionality for playing tracks in voice channels given search terms or urls, implemented with +wavelink. """ import datetime -import functools import json import logging import re @@ -14,6 +13,7 @@ import wavelink from discord import app_commands from discord.ext import commands +from discord.utils import MISSING from wavelink.types.filters import FilterPayload import beira @@ -23,9 +23,6 @@ LOGGER = logging.getLogger(__name__) -escape_markdown = functools.partial(discord.utils.escape_markdown, as_needed=True) - - COMMON_FILTERS: dict[str, FilterPayload] = { "nightcore": {"timescale": {"speed": 1.25, "pitch": 1.3}}, "vaporwave": {"timescale": {"speed": 0.8, "pitch": 0.8}}, @@ -38,8 +35,8 @@ def create_track_embed(title: str, track: wavelink.Playable) -> discord.Embed: icon = EMOJI_STOCK.get(type(track).__name__, "\N{MUSICAL NOTE}") title = f"{icon} {title}" uri = track.uri or "" - author = escape_markdown(track.author) - track_title = escape_markdown(track.title) + author = discord.utils.escape_markdown(track.author, as_needed=True) + track_title = discord.utils.escape_markdown(track.title, as_needed=True) try: end_time = str(datetime.timedelta(milliseconds=track.length)) @@ -56,7 +53,6 @@ def create_track_embed(title: str, track: wavelink.Playable) -> discord.Embed: if track.album.name: embed.add_field(name="Album", value=track.album.name) - # FIXME: Test whether setting on a playlist's extras will set on contained tracks' extras. if requester := getattr(track.extras, "requester", None): embed.add_field(name="Requested By", value=requester) @@ -66,7 +62,7 @@ def create_track_embed(title: str, track: wavelink.Playable) -> discord.Embed: class InvalidShortTimeFormat(app_commands.AppCommandError): """Exception raised when a given input does not match the short time format needed as a command parameter. - This inherits from :exc:`app_commands.AppCommandError`. + This inherits from app_commands.AppCommandError. """ def __init__(self, value: str, *args: object) -> None: @@ -110,12 +106,12 @@ def format_page(self) -> discord.Embed: class ExtraPlayer(wavelink.Player): - """A version of `wavelink.Player` with autoplay set to partial.""" + """A version of wavelink.Player with autoplay set to partial.""" def __init__( self, - client: discord.Client = discord.utils.MISSING, - channel: discord.abc.Connectable = discord.utils.MISSING, + client: discord.Client = MISSING, + channel: discord.abc.Connectable = MISSING, *, nodes: list[wavelink.Node] | None = None, ) -> None: @@ -131,13 +127,16 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{MUSICAL NOTE}") async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing """Catch errors from commands inside this cog.""" + if ctx.error_handled: + return + embed = discord.Embed(title="Music Error", description="Something went wrong with this command.") # Extract the original error. @@ -147,17 +146,18 @@ async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: if isinstance(error, commands.MissingPermissions): embed.description = "You don't have permission to do this." + ctx.error_handled = True elif isinstance(error, beira.NotInBotVoiceChannel): embed.description = "You're not in the same voice channel as the bot." + ctx.error_handled = True elif isinstance(error, InvalidShortTimeFormat): embed.description = error.message + ctx.error_handled = True elif isinstance(error, app_commands.TransformerError): if err := error.__cause__: embed.description = err.args[0] else: embed.description = f"Couldn't convert `{error.value}` into a track." - else: - LOGGER.exception("Exception: %s", error, exc_info=error) await ctx.send(embed=embed) @@ -207,7 +207,15 @@ async def music(self, ctx: beira.GuildContext) -> None: @music.command() async def connect(self, ctx: beira.GuildContext, channel: discord.VoiceChannel | None = None) -> None: - """Join a voice channel.""" + """Join a voice channel. + + Parameters + ---------- + ctx: `beira.GuildContext` + The invocation context. + channel: `discord.VoiceChannel`, optional + The channel to join. + """ vc: wavelink.Player | None = ctx.voice_client @@ -352,7 +360,7 @@ async def current(self, ctx: beira.GuildContext) -> None: async def queue(self, ctx: beira.GuildContext) -> None: """Music queue-related commands. By default, this displays everything in the queue. - Use `play` to add things to the queue. + Use `/play` to add things to the queue. """ vc: wavelink.Player | None = ctx.voice_client @@ -483,7 +491,7 @@ async def loop(self, ctx: beira.GuildContext, loop: Literal["All Tracks", "Curre ---------- ctx: `beira.GuildContext` The invocation context. - loop: Literal["All Tracks", "Current Track", "Off"] + loop: `Literal["All Tracks", "Current Track", "Off"]`, default="Off" The loop settings. "All Tracks" loops everything in the queue, "Current Track" loops the playing track, and "Off" resets all looping. """ @@ -621,9 +629,9 @@ async def muse_import(self, ctx: beira.GuildContext, import_file: discord.Attach Parameters ---------- - ctx: beira.GuildContext + ctx: `beira.GuildContext` The invocation context. - import_file: discord.Attachment + import_file: `discord.Attachment` A JSON file with track information to recreate the queue with. May be created by /export. """ @@ -656,13 +664,13 @@ async def muse_import_error(self, ctx: beira.Context, error: commands.CommandErr actual_error = error.__cause__ or error if isinstance(actual_error, discord.HTTPException): - error_text = f"Bad input: {actual_error.text}" + await ctx.send(f"Bad input: {actual_error.text}") + ctx.error_handled = True elif isinstance(actual_error, json.JSONDecodeError): - error_text = "Bad input: Given attachment is formatted incorrectly." + await ctx.send("Bad input: Given attachment is formatted incorrectly.") + ctx.error_handled = True else: - error_text = "Error: Failed to import attachment." - - await ctx.send(error_text) + await ctx.send("Error: Failed to import attachment.") @muse_import.before_invoke async def import_ensure_voice(self, ctx: beira.GuildContext) -> None: @@ -682,6 +690,4 @@ async def import_ensure_voice(self, ctx: beira.GuildContext) -> None: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(MusicCog(bot)) diff --git a/src/beira/exts/other.py b/src/beira/exts/other.py index a4afb31..37a9ef7 100644 --- a/src/beira/exts/other.py +++ b/src/beira/exts/other.py @@ -1,4 +1,4 @@ -"""misc.py: A cog for testing slash and hybrid command functionality.""" +"""A cog for miscellaneous commands.""" import asyncio import colorsys @@ -133,21 +133,13 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{WOMANS SANDAL}") async def cog_unload(self) -> None: self.bot.tree.remove_command(context_menu_meowify.name, type=context_menu_meowify.type) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.hybrid_command() async def hello(self, ctx: beira.Context) -> None: """Get a "Hello, World!" response.""" @@ -214,13 +206,7 @@ async def quote(self, ctx: beira.Context, *, message: discord.Message) -> None: @commands.hybrid_command(name="ping") async def ping_(self, ctx: beira.Context) -> None: - """Display the time necessary for the bot to communicate with Discord. - - Parameters - ---------- - ctx: `beira.Context` - The invocation context. - """ + """Display the time necessary for the bot to communicate with Discord.""" ws_ping = self.bot.latency * 1000 @@ -313,6 +299,4 @@ async def inspire_me(self, ctx: beira.Context) -> None: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(OtherCog(bot)) diff --git a/src/beira/exts/patreon.py b/src/beira/exts/patreon.py index b3e0a2a..c2dc876 100644 --- a/src/beira/exts/patreon.py +++ b/src/beira/exts/patreon.py @@ -1,4 +1,4 @@ -"""patreon.py: A cog for checking which Discord members are currently patrons of ACI100. +"""A cog for checking which Discord members are currently patrons of ACI100. Work in progress to make the view portion functional for M J Bradley. """ @@ -14,14 +14,14 @@ from discord.ext import commands, tasks import beira -from beira.utils import PaginatedSelectView +from beira.utils import EMOJI_URL, PaginatedSelectView LOGGER = logging.getLogger(__name__) CAMPAIGN_BASE = "https://www.patreon.com/api/oauth2/v2/campaigns" INFO_EMOJI = discord.PartialEmoji.from_str("<:icons_info:880113401207095346>") -ACI100_ICON_URL = "https://cdn.discordapp.com/emojis/1077980959569362994.webp?size=48&quality=lossless" +ACI100_ICON_URL = EMOJI_URL.format(1077980959569362994) class PatreonMember(msgspec.Struct): @@ -95,7 +95,7 @@ def format_page(self) -> discord.Embed: class PatreonCheckCog(commands.Cog, name="Patreon"): """A cog for Patreon-related tasks, like checking which Discord members are currently patrons of ACI100. - In development. + A work in progress. """ def __init__(self, bot: beira.Beira) -> None: @@ -105,7 +105,7 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="patreon", id=1077980959569362994) @@ -129,14 +129,6 @@ async def cog_check(self, ctx: beira.Context) -> bool: # type: ignore # Narrowi original = commands.is_owner().predicate return await original(ctx) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("Error in Patreon Cog", exc_info=error) - async def _get_patreon_roles(self) -> None: await self.bot.wait_until_ready() @@ -272,8 +264,6 @@ async def get_current_actual_patrons(self) -> None: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(PatreonCheckCog(bot)) diff --git a/src/beira/exts/snowball/__init__.py b/src/beira/exts/snowball/__init__.py index e107e63..5216ece 100644 --- a/src/beira/exts/snowball/__init__.py +++ b/src/beira/exts/snowball/__init__.py @@ -4,6 +4,4 @@ async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(SnowballCog(bot)) diff --git a/src/beira/exts/snowball/snowball.py b/src/beira/exts/snowball/snowball.py index e1792c0..6c77dfe 100644 --- a/src/beira/exts/snowball/snowball.py +++ b/src/beira/exts/snowball/snowball.py @@ -1,4 +1,4 @@ -"""snowball.py: A snowball cog that implements a version of Discord's 2021 Snowball Bot game. +"""A snowball cog that implements a version of Discord's 2021 Snowball Bot game. Notes ----- @@ -52,7 +52,7 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="snowflake", animated=True, id=1077980648867901531) @@ -64,13 +64,16 @@ async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: Parameters ---------- - ctx: `beira.Context` + ctx: beira.Context The invocation context where the error happened. - error: `Exception` + error: Exception The error that happened. """ - assert ctx.command is not None + assert ctx.command + + if ctx.error_handled: + return # Extract the original error. error = getattr(error, "original", error) @@ -83,15 +86,18 @@ async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: embed.title = "Missing Parameter!" embed.description = "This command needs a target." ctx.command.reset_cooldown(ctx) + ctx.error_handled = True elif isinstance(error, commands.CommandOnCooldown): embed.title = "Command on Cooldown!" embed.description = f"Please wait {error.retry_after:.2f} seconds before trying this command again." + ctx.error_handled = True elif isinstance(error, beira.CannotTargetSelf): embed.title = "No Targeting Yourself!" embed.description = ( "Are you a masochist or do you just like the taste of snow? Regardless, no hitting yourself in the " "face." ) + ctx.error_handled = True else: embed.title = f"{ctx.command.name}: Unknown Command Error" embed.description = ( diff --git a/src/beira/exts/snowball/utils.py b/src/beira/exts/snowball/utils.py index c331b02..e24018b 100644 --- a/src/beira/exts/snowball/utils.py +++ b/src/beira/exts/snowball/utils.py @@ -25,13 +25,13 @@ class SnowballRecord(msgspec.Struct): Attributes ---------- - hits: `int` + hits: int The number of snowballs used that the member just hit people with. - misses: `int` + misses: int The number of snowballs used the member just tried to hit someone with and missed. - kos: `int' + kos: int The number of hits the member just took. - stock: `int` + stock: int The change in how many snowballs the member has in stock. """ @@ -88,13 +88,13 @@ class GuildSnowballSettings(msgspec.Struct): Attributes ---------- - guild_id: `int`, default=0 + guild_id: int, default=0 The guild these settings apply to. Defaults to 0. - hit_odds: `float`, default=0.6 + hit_odds: float, default=0.6 Chance of hitting someone with a snowball. Defaults to 0.6. - stock_cap: `int`, default=100 + stock_cap: int, default=100 Maximum number of snowballs regular members can hold in their inventory. Defaults to 100. - transfer_cap: `int`, default=10 + transfer_cap: int, default=10 Maximum number of snowballs that can be gifted or stolen. Defaults to 10. """ @@ -134,20 +134,20 @@ class SnowballSettingsModal(discord.ui.Modal): Parameters ---------- - default_settings: `SnowballSettings` + default_settings: SnowballSettings The current snowball-related settings for the guild. Attributes ---------- - hit_odds_input: `discord.ui.TextInput` + hit_odds_input: discord.ui.TextInput An editable text field showing the current hit odds for this guild. - stock_cap_input: `discord.ui.TextInput` + stock_cap_input: discord.ui.TextInput An editable text field showing the current stock cap for this guild. - transfer_cap_input: `discord.ui.TextInput` + transfer_cap_input: discord.ui.TextInput An editable text field showing the current transfer cap for this guild. - default_settings: `SnowballSettings` + default_settings: SnowballSettings The current snowball-related settings for the guild. - new_settings: `SnowballSettings`, optional + new_settings: SnowballSettings, optional The new snowball-related settings for this guild from user input. """ @@ -227,14 +227,14 @@ class SnowballSettingsView(discord.ui.View): Parameters ---------- - guild_settings: `SnowballSettings` + guild_settings: SnowballSettings The current snowball-related settings for the guild. Attributes ---------- - settings: `SnowballSettings` + settings: SnowballSettings The current snowball-related settings for the guild. - message: `discord.Message` + message: discord.Message The message an instance of this view is attached to. """ @@ -326,9 +326,9 @@ def collect_cooldown(ctx: beira.Context) -> commands.Cooldown | None: if ctx.author.id in exempt: return None - if ctx.guild and (ctx.guild.id in testing_guild_ids): per = 1.0 + return commands.Cooldown(rate, per) @@ -344,9 +344,9 @@ def transfer_cooldown(ctx: beira.Context) -> commands.Cooldown | None: if ctx.author.id in exempt: return None - if ctx.guild and (ctx.guild.id in testing_guild_ids): per = 2.0 + return commands.Cooldown(rate, per) @@ -362,7 +362,7 @@ def steal_cooldown(ctx: beira.Context) -> commands.Cooldown | None: if ctx.author.id in exempt: return None - if ctx.guild and (ctx.guild.id in testing_guild_ids): per = 2.0 + return commands.Cooldown(rate, per) diff --git a/src/beira/exts/starkid.py b/src/beira/exts/starkid.py index 1956687..79177a0 100644 --- a/src/beira/exts/starkid.py +++ b/src/beira/exts/starkid.py @@ -1,4 +1,4 @@ -"""starkid.py: A cog for StarKid-related commands and functionality. +"""A cog for StarKid-related commands and functionality. Shoutout to Theo and Ali for inspiration, as well as the whole StarKid server. """ @@ -22,7 +22,7 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="starkid", id=1077980709802758215) @@ -40,6 +40,4 @@ async def nightmare_of_black(self, ctx: beira.Context) -> None: async def setup(bot: beira.Beira) -> None: - """Connects cog to bot.""" - await bot.add_cog(StarKidCog(bot)) diff --git a/src/beira/exts/story_search.py b/src/beira/exts/story_search.py index 518ec49..45e369d 100644 --- a/src/beira/exts/story_search.py +++ b/src/beira/exts/story_search.py @@ -1,5 +1,4 @@ -""" -story_search.py: This cog is meant to provide functionality for searching the text of some books. +"""This cog is meant to provide functionality for searching the text of some books. Currently supports most long-form ACI100 works and M J Bradley's A Cadmean Victory Remastered. """ @@ -37,6 +36,13 @@ def markdownify(html: str, **kwargs: object) -> str: ... AO3_EMOJI = discord.PartialEmoji.from_str(EMOJI_STOCK["ao3"]) assert AO3_EMOJI.id +ACI100_STORY_CHOICES = [ + discord.app_commands.Choice(name="Ashes of Chaos", value="aoc"), + discord.app_commands.Choice(name="Conjoining of Paragons", value="cop"), + discord.app_commands.Choice(name="Fabric of Fate", value="fof"), + discord.app_commands.Choice(name="Perversion of Purity", value="pop"), +] + class StoryInfo(msgspec.Struct): """A class to hold all the information about each story.""" @@ -135,16 +141,16 @@ class AO3StoryHtmlData(msgspec.Struct): class StoryQuoteView(PaginatedEmbedView[tuple[str, str, str]]): - """A subclass of `PaginatedEmbedView` that handles paginated embeds, specifically for quotes from a story. + """A subclass of PaginatedEmbedView that handles paginated embeds, specifically for quotes from a story. Parameters ---------- *args - Positional arguments the normal initialization of an `PaginatedEmbedView`. See that class for more info. + Positional arguments the normal initialization of an PaginatedEmbedView. See that class for more info. story_data: StoryInfo The story's data and metadata, including full name, author name, and image representation. **kwargs - Keyword arguments the normal initialization of an `PaginatedEmbedView`. See that class for more info. + Keyword arguments the normal initialization of an PaginatedEmbedView. See that class for more info. Attributes ---------- @@ -186,16 +192,16 @@ def format_page(self) -> discord.Embed: class AO3StoryQuoteView(PaginatedEmbedView[tuple[str, str]]): - """A subclass of `PaginatedEmbedView` that handles paginated embeds, specifically for quotes from a story. + """A subclass of PaginatedEmbedView that handles paginated embeds, specifically for quotes from a story. Parameters ---------- *args - Positional arguments the normal initialization of an `PaginatedEmbedView`. See that class for more info. + Positional arguments the normal initialization of an PaginatedEmbedView. See that class for more info. story_data: StoryInfo The story's data and metadata, including full name, author name, and image representation. **kwargs - Keyword arguments the normal initialization of an `PaginatedEmbedView`. See that class for more info. + Keyword arguments the normal initialization of an PaginatedEmbedView. See that class for more info. Attributes ---------- @@ -241,14 +247,9 @@ def format_page(self) -> discord.Embed: class StorySearchCog(commands.Cog, name="Quote Search"): """A cog with commands for people to search the text of some ACI100 books while in Discord. - Parameters - ---------- - bot: `Beira` - The main Discord bot this cog is a part of. - Attributes ---------- - story_records: dict + story_records: `dict[str, StoryInfo]` The dictionary holding the metadata and text for all stories being scanned. """ @@ -259,7 +260,7 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{BOOKS}") @@ -274,14 +275,6 @@ async def cog_load(self) -> None: if work.is_file() and work.name.endswith("text.md"): self.load_story_text(work) - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @classmethod def load_story_text(cls, filepath: Traversable) -> None: """Load the story metadata and text.""" @@ -390,13 +383,7 @@ def _binary_search_text(cls, story: str, list_of_indices: list[int], index: int) @commands.hybrid_command() async def random_text(self, ctx: beira.Context) -> None: - """Display a random line from the story. - - Parameters - ---------- - ctx: `beira.Context` - The invocation context where the command was called. - """ + """Display a random line from the story.""" # Randomly choose an ACI100 story. story = random.choice([key for key in self.story_records if key != "acvr"]) @@ -422,14 +409,7 @@ async def random_text(self, ctx: beira.Context) -> None: await ctx.send(embed=embed) @commands.hybrid_command() - @discord.app_commands.choices( - story=[ - discord.app_commands.Choice(name="Ashes of Chaos", value="aoc"), - discord.app_commands.Choice(name="Conjoining of Paragons", value="cop"), - discord.app_commands.Choice(name="Fabric of Fate", value="fof"), - discord.app_commands.Choice(name="Perversion of Purity", value="pop"), - ], - ) + @discord.app_commands.choices(story=ACI100_STORY_CHOICES) async def search_text(self, ctx: beira.Context, story: str, *, query: str) -> None: """Search the works of ACI100 for a word or phrase. @@ -468,14 +448,7 @@ async def search_cadmean(self, ctx: beira.Context, *, query: str) -> None: view.message = message @commands.hybrid_command() - @discord.app_commands.choices( - known_story=[ - discord.app_commands.Choice(name="Ashes of Chaos", value="aoc"), - discord.app_commands.Choice(name="Conjoining of Paragons", value="cop"), - discord.app_commands.Choice(name="Fabric of Fate", value="fof"), - discord.app_commands.Choice(name="Perversion of Purity", value="pop"), - ], - ) + @discord.app_commands.choices(known_story=ACI100_STORY_CHOICES) async def find_text( self, ctx: beira.Context, diff --git a/src/beira/exts/timing.py b/src/beira/exts/timing.py index 0fcc1cc..7dbc6d0 100644 --- a/src/beira/exts/timing.py +++ b/src/beira/exts/timing.py @@ -102,9 +102,9 @@ async def timezone_set(self, ctx: beira.Context, tz: str) -> None: Parameters ---------- - ctx: beira.Context + ctx: `beira.Context` The command invocation context. - tz: str + tz: `str` The timezone. """ @@ -168,8 +168,8 @@ async def timezone_autocomplete( ][:25] -# TODO: Complete and enable later. async def setup(bot: beira.Beira) -> None: """Connects cog to bot.""" + # TODO: Complete and enable later. # await bot.add_cog(TimingCog(bot)) # noqa: ERA001 diff --git a/src/beira/exts/todo.py b/src/beira/exts/todo.py index 0772bf7..88f5314 100644 --- a/src/beira/exts/todo.py +++ b/src/beira/exts/todo.py @@ -1,6 +1,4 @@ -""" -todo.py: A module/cog for handling todo lists made in Discord and stored in a database. -""" +"""A module/cog for handling todo lists made in Discord and stored in a database.""" import datetime import logging @@ -56,7 +54,7 @@ async def change_completion(self, conn: Pool_alias | Connection_alias) -> Self: Parameters ---------- - conn: `asyncpg.Pool` | `asyncpg.Connection` + conn: asyncpg.Pool | asyncpg.Connection The connection/pool that will be used to make this database command. """ @@ -72,9 +70,9 @@ async def update(self, conn: Pool_alias | Connection_alias, updated_content: str Parameters ---------- - conn: `asyncpg.Pool` | `asyncpg.Connection` + conn: asyncpg.Pool | asyncpg.Connection The connection/pool that will be used to make this database command. - updated_content: `str` + updated_content: str The new to-do content. """ @@ -87,7 +85,7 @@ async def delete(self, conn: Pool_alias | Connection_alias) -> None: Parameters ---------- - conn: `asyncpg.Pool` | `asyncpg.Connection` + conn: asyncpg.Pool | asyncpg.Connection The connection/pool that will be used to make this database command. """ @@ -98,12 +96,12 @@ def display_embed(self, *, to_be_deleted: bool = False) -> discord.Embed: Parameters ---------- - to_be_deleted: `bool`, default=False + to_be_deleted: bool, default=False Whether the given to-do item is going to be deleted from the database. Defaults to False. Returns ------- - `discord.Embed` + discord.Embed The formatted embed for the to-do item. """ @@ -137,16 +135,16 @@ class TodoModal(discord.ui.Modal, title="What do you want to do?"): Parameters ---------- - existing_content: `str`, default="" + existing_content: str, default="" If working with an existing to-do item, this is the current content of that item to be edited. Defaults to an empty string. Attributes ---------- - content: `discord.ui.TextInput` + content: discord.ui.TextInput The text box that will allow a user to enter or edit a to-do item's content. If editing, existing content is added as "default". - interaction: `discord.Interaction` + interaction: discord.Interaction The interaction of the user with the modal. Only populates on submission. """ @@ -187,10 +185,10 @@ class TodoCompleteButton(discord.ui.Button[TodoViewABC]): Parameters ---------- - completed_at: `datetime.datetime`, optional + completed_at: datetime.datetime, optional An optional completion time for the to-do item in the parent view. Determines the button's initial look. **kwargs - Arbitrary keywords arguments primarily for `discord.ui.Button`. See that class for more information. + Arbitrary keywords arguments primarily for discord.ui.Button. See that class for more information. """ def __init__(self, completed_at: datetime.datetime | None = None, **kwargs: Any) -> None: @@ -234,7 +232,7 @@ class TodoEditButton(discord.ui.Button[TodoViewABC]): Parameters ---------- **kwargs - Arbitrary keywords arguments primarily for `discord.ui.Button`. See that class for more information. + Arbitrary keywords arguments primarily for discord.ui.Button. See that class for more information. """ def __init__(self, **kwargs: Any) -> None: @@ -274,7 +272,7 @@ class TodoDeleteButton(discord.ui.Button[TodoViewABC]): Parameters ---------- **kwargs - Arbitrary keywords arguments primarily for `discord.ui.Button`. See that class for more information. + Arbitrary keywords arguments primarily for discord.ui.Button. See that class for more information. """ def __init__(self, **kwargs: Any) -> None: @@ -297,20 +295,20 @@ class TodoView(TodoViewABC): Parameters ---------- - author_id: `int` + author_id: int The Discord ID of the user that triggered this view. No one else can use it. - todo_item: `TodoItem` + todo_item: TodoItem The to-do item that's being viewed and interacted with. **kwargs - Arbitrary keyword arguments, primarily for `discord.ui.View`. See that class for more information. + Arbitrary keyword arguments, primarily for discord.ui.View. See that class for more information. Attributes ---------- - message: `discord.Message` | None - The message to which the view is attached to, allowing interaction without a `discord.Interaction`. - author: `discord.User` | `discord.Member` + message: discord.Message | None + The message to which the view is attached to, allowing interaction without a discord.Interaction. + author: discord.User | discord.Member The user that triggered this view. No one else can use it. - todo_item: `TodoItem` | None + todo_item: TodoItem | None The to-do item that's being viewed and interacted with. Might be set to None of the record is deleted. """ @@ -336,9 +334,9 @@ async def update_todo(self, interaction: beira.Interaction, updated_item: TodoIt Parameters ---------- - interaction: `beira.Interaction` + interaction: beira.Interaction The interaction that caused this state change. - updated_record: `TodoItem` + updated_record: TodoItem The new version of the to-do item for the view to display. """ @@ -359,9 +357,9 @@ class TodoListView(PaginatedEmbedView[TodoItem], TodoViewABC): Parameters ---------- *args - Variable length argument list, primarily for `PaginatedEmbedView`. + Variable length argument list, primarily for PaginatedEmbedView. **kwargs - Arbitrary keyword arguments, primarily for `PaginatedEmbedView`. See that class for more information. + Arbitrary keyword arguments, primarily for PaginatedEmbedView. See that class for more information. """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -414,9 +412,9 @@ async def update_todo(self, interaction: beira.Interaction, updated_item: TodoIt Parameters ---------- - interaction: `beira.Interaction` + interaction: beira.Interaction The interaction that caused this state change. - updated_record: `TodoItem` + updated_record: TodoItem The new version of the to-do item for the view to display. """ @@ -440,18 +438,10 @@ def __init__(self, bot: beira.Beira) -> None: @property def cog_emoji(self) -> discord.PartialEmoji: - """`discord.PartialEmoji`: A partial emoji representing this cog.""" + """discord.PartialEmoji: A partial emoji representing this cog.""" return discord.PartialEmoji(name="\N{SPIRAL NOTE PAD}") - async def cog_command_error(self, ctx: beira.Context, error: Exception) -> None: # type: ignore # Narrowing - # Extract the original error. - error = getattr(error, "original", error) - if ctx.interaction: - error = getattr(error, "original", error) - - LOGGER.exception("", exc_info=error) - @commands.hybrid_group() async def todo(self, ctx: beira.Context) -> None: """Commands to manage your to-do items.""" diff --git a/src/beira/exts/triggers/__init__.py b/src/beira/exts/triggers/__init__.py index 198baaf..b78dd10 100644 --- a/src/beira/exts/triggers/__init__.py +++ b/src/beira/exts/triggers/__init__.py @@ -5,7 +5,5 @@ async def setup(bot: beira.Beira) -> None: - """Connects cogs to bot.""" - await bot.add_cog(MiscTriggersCog(bot)) await bot.add_cog(RSSNotificationsCog(bot)) diff --git a/src/beira/exts/triggers/misc_triggers.py b/src/beira/exts/triggers/misc_triggers.py index b61fee0..b3aaea6 100644 --- a/src/beira/exts/triggers/misc_triggers.py +++ b/src/beira/exts/triggers/misc_triggers.py @@ -1,4 +1,4 @@ -"""custom_notifications.py: One or more listeners for sending custom notifications based on events.""" +"""One or more listeners for sending custom notifications based on events.""" import asyncio import logging diff --git a/src/beira/exts/triggers/rss_notifications.py b/src/beira/exts/triggers/rss_notifications.py index 1268c53..1209d44 100644 --- a/src/beira/exts/triggers/rss_notifications.py +++ b/src/beira/exts/triggers/rss_notifications.py @@ -10,6 +10,15 @@ import beira +# Potential database schema: +# CREATE TABLE IF NOT EXISTS notifications_tracker( +# notification_id SERIAL PRIMARY KEY, +# notification_url TEXT NOT NULL, +# last_notification TEXT NOT NULL, +# notification_webhook TEXT NOT NULL +# ); + + class NotificationRecord(msgspec.Struct): id: int url: str @@ -23,16 +32,7 @@ def from_record(cls, record: asyncpg.Record, *, session: aiohttp.ClientSession) class RSSNotificationsCog(commands.Cog): - """Cog that uses polling to handle notifications related to various social media and website new posts/updates. - - Potential database schema: - CREATE TABLE IF NOT EXISTS notifications_tracker( - notification_id SERIAL PRIMARY KEY, - notification_url TEXT NOT NULL, - last_notification TEXT NOT NULL, - notification_webhook TEXT NOT NULL - ); - """ + """Cog that uses polling to handle notifications related to various social media and website new posts/updates.""" def __init__(self, bot: beira.Beira) -> None: self.bot = bot diff --git a/src/beira/utils/embeds.py b/src/beira/utils/embeds.py index 387663d..d43a26b 100644 --- a/src/beira/utils/embeds.py +++ b/src/beira/utils/embeds.py @@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__name__) -type AnyEmoji = discord.Emoji | discord.PartialEmoji | str +type _AnyEmoji = discord.Emoji | discord.PartialEmoji | str __all__ = ("StatsEmbed",) @@ -37,7 +37,7 @@ def add_stat_fields( self, *, names: Iterable[object], - emojis: Iterable[AnyEmoji] = ("",), + emojis: Iterable[_AnyEmoji] = ("",), values: Iterable[object], inline: bool = False, emoji_as_header: bool = False, @@ -76,7 +76,7 @@ def add_leaderboard_fields( self, *, ldbd_content: Iterable[Sequence[object]], - ldbd_emojis: Iterable[AnyEmoji] = ("",), + ldbd_emojis: Iterable[_AnyEmoji] = ("",), name_format: str = "| {}", value_format: str = "{}", inline: bool = False, diff --git a/src/beira/utils/extras/formats.py b/src/beira/utils/extras/formats.py index 404d26a..f51f558 100644 --- a/src/beira/utils/extras/formats.py +++ b/src/beira/utils/extras/formats.py @@ -11,9 +11,7 @@ def __format__(self, format_spec: str) -> str: v = self.value singular, _, plural = format_spec.partition("|") plural = plural or f"{singular}s" - if abs(v) != 1: - return f"{v} {plural}" - return f"{v} {singular}" + return f"{v} {plural if (abs(v) != 1) else singular}" def human_join(seq: Sequence[str], delim: str = ", ", final: str = "or") -> str: diff --git a/src/beira/utils/extras/time.py b/src/beira/utils/extras/time.py index a184e77..0ad56e5 100644 --- a/src/beira/utils/extras/time.py +++ b/src/beira/utils/extras/time.py @@ -12,7 +12,6 @@ import beira -from ... import Context # noqa: TID252 from .formats import human_join, plural @@ -68,7 +67,7 @@ def __init__( self.dt = self.dt.astimezone(tzinfo) @classmethod - async def convert(cls, ctx: Context, argument: str) -> Self: + async def convert(cls, ctx: beira.Context, argument: str) -> Self: tzinfo = await ctx.bot.get_user_tzinfo(ctx.author.id) return cls(argument, now=ctx.message.created_at, tzinfo=tzinfo) @@ -84,7 +83,7 @@ def __do_conversion(cls, argument: str) -> relativedelta: data = {k: int(v) for k, v in match.groupdict(default=0).items()} return relativedelta(**data) # type: ignore # None of the regex groups currently fill the date fields. - async def convert(self, ctx: Context, argument: str) -> relativedelta: # type: ignore # Custom context. + async def convert(self, ctx: beira.Context, argument: str) -> relativedelta: # type: ignore # Custom context. try: return self.__do_conversion(argument) except ValueError as e: @@ -126,7 +125,7 @@ def __init__( self._past: bool = self.dt < now @classmethod - async def convert(cls, ctx: Context, argument: str) -> Self: + async def convert(cls, ctx: beira.Context, argument: str) -> Self: tzinfo = await ctx.bot.get_user_tzinfo(ctx.author.id) return cls(argument, now=ctx.message.created_at, tzinfo=tzinfo) @@ -194,7 +193,7 @@ def __init__(self, dt: datetime.datetime): async def ensure_constraints( self, - ctx: Context, + ctx: beira.Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str, @@ -234,7 +233,7 @@ def __init__( self.converter: commands.Converter[str] | None = converter self.default: Any = default - async def convert(self, ctx: Context, argument: str) -> FriendlyTimeResult: # type: ignore # Custom context. # noqa: PLR0915 + async def convert(self, ctx: beira.Context, argument: str) -> FriendlyTimeResult: # type: ignore # Custom context. # noqa: PLR0915 calendar = HumanTime.calendar regex = ShortTime.COMPILED now = ctx.message.created_at diff --git a/src/beira/utils/log.py b/src/beira/utils/log.py index 17733ed..c38ce80 100644 --- a/src/beira/utils/log.py +++ b/src/beira/utils/log.py @@ -16,7 +16,7 @@ class RemoveNoise(logging.Filter): - """Filter for discord.state warnings about "referencing an unknown".""" + """Filter for "discord.state" to only let through warnings about "referencing an unknown".""" def __init__(self) -> None: super().__init__(name="discord.state") diff --git a/src/beira/utils/misc.py b/src/beira/utils/misc.py index fe9d393..234e1f0 100644 --- a/src/beira/utils/misc.py +++ b/src/beira/utils/misc.py @@ -3,11 +3,12 @@ import logging import re import time +from collections.abc import Callable import lxml.html -__all__ = ("catchtime", "html_to_markdown") +__all__ = ("catchtime", "html_to_markdown", "copy_annotations") class catchtime: @@ -83,3 +84,15 @@ def html_to_markdown(node: lxml.html.HtmlElement, *, include_spans: bool = False text.append(child.tail) return "".join(text).strip() + + +def copy_annotations[**P, T](original_func: Callable[P, T]) -> Callable[[Callable[..., object]], Callable[P, T]]: + """A decorator that copies the annotations from one function onto another. + + It may be a lie, but the lie can aid type checkers, IDEs, intellisense, etc. + """ + + def inner(new_func: Callable[..., object]) -> Callable[P, T]: + return new_func # type: ignore # A lie. + + return inner