From 4315b05c6316cf5408923d27e9bd5f55aac34055 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Mon, 19 Aug 2024 22:35:36 +0300 Subject: [PATCH 01/25] [balls] Fix duplicate stats showing in `/balls give` (#341) --- ballsdex/packages/balls/cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ballsdex/packages/balls/cog.py b/ballsdex/packages/balls/cog.py index bfb0aba0..7fd0a0ae 100644 --- a/ballsdex/packages/balls/cog.py +++ b/ballsdex/packages/balls/cog.py @@ -556,8 +556,7 @@ async def give( elif new_player.donation_policy == DonationPolicy.REQUEST_APPROVAL: await interaction.followup.send( f"Hey {user.mention}, {interaction.user.name} wants to give you " - f"{countryball.description(include_emoji=True, bot=self.bot, is_trade=True)} " - f"(`{countryball.attack_bonus:+}%/{countryball.health_bonus:+}%`)!\n" + f"{countryball.description(include_emoji=True, bot=self.bot, is_trade=True)}!\n" "Do you accept this donation?", view=DonationRequest(self.bot, interaction, countryball, new_player), ) From 311b96dbcaed76e323d841cd1f0053dbe923d0e0 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Tue, 20 Aug 2024 22:55:19 +0300 Subject: [PATCH 02/25] [balls] change countryball to `{settings.collectible_name}` and title (#342) * changing countryball to {settings.collectible_name} and title * e --- ballsdex/packages/trade/menu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ballsdex/packages/trade/menu.py b/ballsdex/packages/trade/menu.py index a1e59a45..31fd8d20 100644 --- a/ballsdex/packages/trade/menu.py +++ b/ballsdex/packages/trade/menu.py @@ -419,13 +419,13 @@ async def confirm_button(self, interaction: discord.Interaction, button: Button) if len(self.balls_selected) == 0: return await interaction.followup.send( - "You have not selected any countryballs to add to your proposal.", + f"You have not selected any {settings.collectible_name}s to add to your proposal.", ephemeral=True, ) for ball in self.balls_selected: if ball.is_tradeable is False: return await interaction.followup.send( - f"Countryball #{ball.pk:0X} is not tradeable.", + f"{settings.collectible_name.title()} #{ball.pk:0X} is not tradeable.", ephemeral=True, ) trader.proposal.append(ball) From 6039204d8c837151da7d2daf3404058af0feff0e Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Tue, 20 Aug 2024 22:57:19 +0300 Subject: [PATCH 03/25] [meta] Change `Trade` to `donate` in `balls give` when ball is favorited. (#343) --- ballsdex/packages/balls/cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ballsdex/packages/balls/cog.py b/ballsdex/packages/balls/cog.py index 7fd0a0ae..dad64a61 100644 --- a/ballsdex/packages/balls/cog.py +++ b/ballsdex/packages/balls/cog.py @@ -520,7 +520,7 @@ async def give( view = ConfirmChoiceView(interaction) await interaction.response.send_message( f"This {settings.collectible_name} is a favorite, " - "are you sure you want to trade it?", + "are you sure you want to donate it?", view=view, ephemeral=True, ) @@ -549,7 +549,7 @@ async def give( return if new_player.discord_id in self.bot.blacklist: await interaction.followup.send( - "You cannot donate to a blacklisted user", ephemeral=True + "You cannot donate to a blacklisted user.", ephemeral=True ) await countryball.unlock() return From 3c47c4a7ea54a038a146714dbeb401074b43a99d Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:54:18 +0300 Subject: [PATCH 04/25] [admin] make it impossible to blacklist yourself (#344) --- ballsdex/packages/admin/cog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ballsdex/packages/admin/cog.py b/ballsdex/packages/admin/cog.py index a51e6fc0..3469b4b9 100644 --- a/ballsdex/packages/admin/cog.py +++ b/ballsdex/packages/admin/cog.py @@ -614,6 +614,11 @@ async def blacklist_add( "You must provide either `user` or `user_id`.", ephemeral=True ) return + if user == interaction.user: + await interaction.response.send_message( + "You cannot blacklist yourself!", ephemeral=True + ) + return if not user: try: From 482993c5c261ab5e8344c5d51d02f2334b7b8032 Mon Sep 17 00:00:00 2001 From: Kowlin <10947836+Kowlin@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:10:04 +0200 Subject: [PATCH 05/25] Fix all the file endings and enforce LF (#345) * Renormalized the files * Add .gitattributes * Revert "Renormalized the files" This reverts commit 04a1578d1def15ca1eca68d9f72db4d9982e8fb2. * Renormalize the right files. * Add .tff as binaries to not be normalized --- .gitattributes | 5 + .gitignore | 72 +- ballsdex/__init__.py | 2 +- ballsdex/__main__.py | 654 +++++++------- ballsdex/core/bot.py | 936 ++++++++++----------- ballsdex/core/commands.py | 160 ++-- ballsdex/core/image_generator/image_gen.py | 214 ++--- 7 files changed, 1024 insertions(+), 1019 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a9bf73bc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text eol=lf + +# binary file excludsions +*.png binary +*.tff binary diff --git a/.gitignore b/.gitignore index f5407aeb..88b3a0e2 100755 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,36 @@ -# Python stuff -*.pyc -__pycache__ - -# macOS -.DS_Store - -# Python binaries -*.egg-info -dist -build - -# log files -*.log* - -# Virtual environment -venv -.venv -.python-version - -# Visual Studio code settings -.vscode - -# database -*sqlite3* -*.rdb - -# static -static - -# cache -.pytest_cache - -# settings -config.yml -docker-compose.override.yml +# Python stuff +*.pyc +__pycache__ + +# macOS +.DS_Store + +# Python binaries +*.egg-info +dist +build + +# log files +*.log* + +# Virtual environment +venv +.venv +.python-version + +# Visual Studio code settings +.vscode + +# database +*sqlite3* +*.rdb + +# static +static + +# cache +.pytest_cache + +# settings +config.yml +docker-compose.override.yml diff --git a/ballsdex/__init__.py b/ballsdex/__init__.py index 394e6fb0..68a0e512 100755 --- a/ballsdex/__init__.py +++ b/ballsdex/__init__.py @@ -1 +1 @@ -__version__ = "2.18.1" +__version__ = "2.18.1" diff --git a/ballsdex/__main__.py b/ballsdex/__main__.py index 25380295..4f2f6d28 100755 --- a/ballsdex/__main__.py +++ b/ballsdex/__main__.py @@ -1,327 +1,327 @@ -import argparse -import asyncio -import functools -import logging -import logging.handlers -import os -import sys -import time -from pathlib import Path -from signal import SIGTERM - -import discord -import yarl -from aerich import Command -from discord.ext.commands import when_mentioned_or -from rich import print -from tortoise import Tortoise - -from ballsdex import __version__ as bot_version -from ballsdex.core.bot import BallsDexBot -from ballsdex.logging import init_logger -from ballsdex.settings import read_settings, settings, update_settings, write_default_settings - -discord.voice_client.VoiceClient.warn_nacl = False # disable PyNACL warning -log = logging.getLogger("ballsdex") - -TORTOISE_ORM = { - "connections": {"default": os.environ.get("BALLSDEXBOT_DB_URL")}, - "apps": { - "models": { - "models": ["ballsdex.core.models", "aerich.models"], - "default_connection": "default", - }, - }, -} - - -class CLIFlags(argparse.Namespace): - version: bool - config_file: Path - reset_settings: bool - disable_rich: bool - debug: bool - dev: bool - - -def parse_cli_flags(arguments: list[str]) -> CLIFlags: - parser = argparse.ArgumentParser( - prog="BallsDex bot", description="Collect and exchange countryballs on Discord" - ) - parser.add_argument("--version", "-V", action="store_true", help="Display the bot's version") - parser.add_argument( - "--config-file", type=Path, help="Set the path to config.yml", default=Path("./config.yml") - ) - parser.add_argument( - "--reset-settings", - action="store_true", - help="Reset the config file with the latest default configuration", - ) - parser.add_argument("--disable-rich", action="store_true", help="Disable rich log format") - parser.add_argument("--debug", action="store_true", help="Enable debug logs") - parser.add_argument("--dev", action="store_true", help="Enable developer mode") - args = parser.parse_args(arguments, namespace=CLIFlags()) - return args - - -def reset_settings(path: Path): - write_default_settings(path) - print(f"[green]A new settings file has been written at [blue]{path}[/blue].[/green]") - print("[yellow]Configure the [bold]discord-token[/bold] value and restart the bot.[/yellow]") - sys.exit(0) - - -def print_welcome(): - print("[green]{0:-^50}[/green]".format(f" {settings.bot_name} bot ")) - print("[green]{0: ^50}[/green]".format(f" Collect {settings.collectible_name}s ")) - print("[blue]{0:^50}[/blue]".format("Discord bot made by El Laggron")) - print("") - print(" [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format("Bot version:", bot_version)) - print( - " [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format( - "Discord.py version:", discord.__version__ - ) - ) - print("") - - -def patch_gateway(proxy_url: str): - """This monkeypatches discord.py in order to be able to use a custom gateway URL. - - Parameters - ---------- - proxy_url : str - The URL of the gateway proxy to use. - """ - - class ProductionHTTPClient(discord.http.HTTPClient): # type: ignore - async def get_gateway(self, **_): - return f"{proxy_url}?encoding=json&v=10" - - async def get_bot_gateway(self, **_): - try: - data = await self.request( - discord.http.Route("GET", "/gateway/bot") # type: ignore - ) - except discord.HTTPException as exc: - raise discord.GatewayNotFound() from exc - return data["shards"], f"{proxy_url}?encoding=json&v=10" - - class ProductionDiscordWebSocket(discord.gateway.DiscordWebSocket): # type: ignore - def is_ratelimited(self): - return False - - async def debug_send(self, data, /): - self._dispatch("socket_raw_send", data) - await self.socket.send_str(data) - - async def send(self, data, /): - await self.socket.send_str(data) - - class ProductionReconnectWebSocket(Exception): - def __init__(self, shard_id: int | None, *, resume: bool = False): - self.shard_id: int | None = shard_id - self.resume: bool = False - self.op: str = "IDENTIFY" - - def is_ws_ratelimited(self): - return False - - async def before_identify_hook(self, shard_id: int | None, *, initial: bool = False): - pass - - discord.http.HTTPClient.get_gateway = ProductionHTTPClient.get_gateway # type: ignore - discord.http.HTTPClient.get_bot_gateway = ProductionHTTPClient.get_bot_gateway # type: ignore - discord.gateway.DiscordWebSocket._keep_alive = None # type: ignore - discord.gateway.DiscordWebSocket.is_ratelimited = ( # type: ignore - ProductionDiscordWebSocket.is_ratelimited - ) - discord.gateway.DiscordWebSocket.debug_send = ( # type: ignore - ProductionDiscordWebSocket.debug_send - ) - discord.gateway.DiscordWebSocket.send = ProductionDiscordWebSocket.send # type: ignore - discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(proxy_url) # type: ignore - discord.gateway.ReconnectWebSocket.__init__ = ( # type: ignore - ProductionReconnectWebSocket.__init__ - ) - BallsDexBot.is_ws_ratelimited = is_ws_ratelimited - BallsDexBot.before_identify_hook = before_identify_hook - - -async def shutdown_handler(bot: BallsDexBot, signal_type: str | None = None): - if signal_type: - log.info(f"Received {signal_type}, stopping the bot...") - else: - log.info("Shutting down the bot...") - try: - await asyncio.wait_for(bot.close(), timeout=10) - finally: - pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] - [task.cancel() for task in pending] - try: - await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), timeout=5) - except asyncio.TimeoutError: - log.error( - f"Timed out cancelling tasks. {len([t for t in pending if not t.cancelled])}/" - f"{len(pending)} tasks are still pending!" - ) - sys.exit(0 if signal_type else 1) - - -def global_exception_handler(bot: BallsDexBot, loop: asyncio.AbstractEventLoop, context: dict): - """ - Logs unhandled exceptions in other tasks - """ - exc = context.get("exception") - # These will get handled later when it *also* kills loop.run_forever - if exc is not None and isinstance(exc, (KeyboardInterrupt, SystemExit)): - return - log.critical( - "Caught unhandled exception in %s:\n%s", - context.get("future", "event loop"), - context["message"], - exc_info=exc, - ) - - -def bot_exception_handler(bot: BallsDexBot, bot_task: asyncio.Future): - """ - This is set as a done callback for the bot - - Must be used with functools.partial - - If the main bot.run dies for some reason, - we don't want to swallow the exception and hang. - """ - try: - bot_task.result() - except (SystemExit, KeyboardInterrupt, asyncio.CancelledError): - pass # Handled by the global_exception_handler, or cancellation - except Exception as exc: - log.critical("The main bot task didn't handle an exception and has crashed", exc_info=exc) - log.warning("Attempting to die as gracefully as possible...") - asyncio.create_task(shutdown_handler(bot)) - - -class RemoveWSBehindMsg(logging.Filter): - """Filter used when gateway proxy is set, the "behind" message is meaningless in this case.""" - - def __init__(self): - super().__init__(name="discord.gateway") - - def filter(self, record): - if record.levelname == "WARNING" and "Can't keep up" in record.msg: - return False - - return True - - -async def init_tortoise(db_url: str): - log.debug(f"Database URL: {db_url}") - await Tortoise.init(config=TORTOISE_ORM) - - # migrations - command = Command(TORTOISE_ORM, app="models") - await command.init() - migrations = await command.upgrade() - if migrations: - log.info(f"Ran {len(migrations)} migrations: {', '.join(migrations)}") - - -def main(): - bot = None - server = None - cli_flags = parse_cli_flags(sys.argv[1:]) - if cli_flags.version: - print(f"BallsDex Discord bot - {bot_version}") - sys.exit(0) - if cli_flags.reset_settings: - print("[yellow]Resetting configuration file.[/yellow]") - reset_settings(cli_flags.config_file) - - try: - read_settings(cli_flags.config_file) - except FileNotFoundError: - print("[yellow]The config file could not be found, generating a default one.[/yellow]") - reset_settings(cli_flags.config_file) - else: - update_settings(cli_flags.config_file) - - print_welcome() - queue_listener: logging.handlers.QueueListener | None = None - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - queue_listener = init_logger(cli_flags.disable_rich, cli_flags.debug) - - token = settings.bot_token - if not token: - log.error("Token not found!") - print("[red]You must provide a token inside the config.yml file.[/red]") - time.sleep(1) - sys.exit(0) - - db_url = os.environ.get("BALLSDEXBOT_DB_URL", None) - if not db_url: - log.error("Database URL not found!") - print("[red]You must provide a DB URL with the BALLSDEXBOT_DB_URL env var.[/red]") - time.sleep(1) - sys.exit(0) - - if settings.gateway_url is not None: - log.info("Using custom gateway URL: %s", settings.gateway_url) - patch_gateway(settings.gateway_url) - logging.getLogger("discord.gateway").addFilter(RemoveWSBehindMsg()) - - prefix = settings.prefix - - try: - loop.run_until_complete(init_tortoise(db_url)) - except Exception: - log.exception("Failed to connect to database.") - return # will exit with code 1 - log.info("Tortoise ORM and database ready.") - - bot = BallsDexBot( - command_prefix=when_mentioned_or(prefix), - dev=cli_flags.dev, # type: ignore - shard_count=settings.shard_count, - ) - - exc_handler = functools.partial(global_exception_handler, bot) - loop.set_exception_handler(exc_handler) - loop.add_signal_handler( - SIGTERM, lambda: loop.create_task(shutdown_handler(bot, "SIGTERM")) - ) - - log.info("Initialized bot, connecting to Discord...") - future = loop.create_task(bot.start(token)) - bot_exc_handler = functools.partial(bot_exception_handler, bot) - future.add_done_callback(bot_exc_handler) - - loop.run_forever() - except KeyboardInterrupt: - if bot is not None: - loop.run_until_complete(shutdown_handler(bot, "Ctrl+C")) - except Exception: - log.critical("Unhandled exception.", exc_info=True) - if bot is not None: - loop.run_until_complete(shutdown_handler(bot)) - finally: - if queue_listener: - queue_listener.stop() - loop.run_until_complete(loop.shutdown_asyncgens()) - if server is not None: - loop.run_until_complete(server.stop()) - if Tortoise._inited: - loop.run_until_complete(Tortoise.close_connections()) - asyncio.set_event_loop(None) - loop.stop() - loop.close() - sys.exit(bot._shutdown if bot else 1) - - -if __name__ == "__main__": - main() +import argparse +import asyncio +import functools +import logging +import logging.handlers +import os +import sys +import time +from pathlib import Path +from signal import SIGTERM + +import discord +import yarl +from aerich import Command +from discord.ext.commands import when_mentioned_or +from rich import print +from tortoise import Tortoise + +from ballsdex import __version__ as bot_version +from ballsdex.core.bot import BallsDexBot +from ballsdex.logging import init_logger +from ballsdex.settings import read_settings, settings, update_settings, write_default_settings + +discord.voice_client.VoiceClient.warn_nacl = False # disable PyNACL warning +log = logging.getLogger("ballsdex") + +TORTOISE_ORM = { + "connections": {"default": os.environ.get("BALLSDEXBOT_DB_URL")}, + "apps": { + "models": { + "models": ["ballsdex.core.models", "aerich.models"], + "default_connection": "default", + }, + }, +} + + +class CLIFlags(argparse.Namespace): + version: bool + config_file: Path + reset_settings: bool + disable_rich: bool + debug: bool + dev: bool + + +def parse_cli_flags(arguments: list[str]) -> CLIFlags: + parser = argparse.ArgumentParser( + prog="BallsDex bot", description="Collect and exchange countryballs on Discord" + ) + parser.add_argument("--version", "-V", action="store_true", help="Display the bot's version") + parser.add_argument( + "--config-file", type=Path, help="Set the path to config.yml", default=Path("./config.yml") + ) + parser.add_argument( + "--reset-settings", + action="store_true", + help="Reset the config file with the latest default configuration", + ) + parser.add_argument("--disable-rich", action="store_true", help="Disable rich log format") + parser.add_argument("--debug", action="store_true", help="Enable debug logs") + parser.add_argument("--dev", action="store_true", help="Enable developer mode") + args = parser.parse_args(arguments, namespace=CLIFlags()) + return args + + +def reset_settings(path: Path): + write_default_settings(path) + print(f"[green]A new settings file has been written at [blue]{path}[/blue].[/green]") + print("[yellow]Configure the [bold]discord-token[/bold] value and restart the bot.[/yellow]") + sys.exit(0) + + +def print_welcome(): + print("[green]{0:-^50}[/green]".format(f" {settings.bot_name} bot ")) + print("[green]{0: ^50}[/green]".format(f" Collect {settings.collectible_name}s ")) + print("[blue]{0:^50}[/blue]".format("Discord bot made by El Laggron")) + print("") + print(" [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format("Bot version:", bot_version)) + print( + " [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format( + "Discord.py version:", discord.__version__ + ) + ) + print("") + + +def patch_gateway(proxy_url: str): + """This monkeypatches discord.py in order to be able to use a custom gateway URL. + + Parameters + ---------- + proxy_url : str + The URL of the gateway proxy to use. + """ + + class ProductionHTTPClient(discord.http.HTTPClient): # type: ignore + async def get_gateway(self, **_): + return f"{proxy_url}?encoding=json&v=10" + + async def get_bot_gateway(self, **_): + try: + data = await self.request( + discord.http.Route("GET", "/gateway/bot") # type: ignore + ) + except discord.HTTPException as exc: + raise discord.GatewayNotFound() from exc + return data["shards"], f"{proxy_url}?encoding=json&v=10" + + class ProductionDiscordWebSocket(discord.gateway.DiscordWebSocket): # type: ignore + def is_ratelimited(self): + return False + + async def debug_send(self, data, /): + self._dispatch("socket_raw_send", data) + await self.socket.send_str(data) + + async def send(self, data, /): + await self.socket.send_str(data) + + class ProductionReconnectWebSocket(Exception): + def __init__(self, shard_id: int | None, *, resume: bool = False): + self.shard_id: int | None = shard_id + self.resume: bool = False + self.op: str = "IDENTIFY" + + def is_ws_ratelimited(self): + return False + + async def before_identify_hook(self, shard_id: int | None, *, initial: bool = False): + pass + + discord.http.HTTPClient.get_gateway = ProductionHTTPClient.get_gateway # type: ignore + discord.http.HTTPClient.get_bot_gateway = ProductionHTTPClient.get_bot_gateway # type: ignore + discord.gateway.DiscordWebSocket._keep_alive = None # type: ignore + discord.gateway.DiscordWebSocket.is_ratelimited = ( # type: ignore + ProductionDiscordWebSocket.is_ratelimited + ) + discord.gateway.DiscordWebSocket.debug_send = ( # type: ignore + ProductionDiscordWebSocket.debug_send + ) + discord.gateway.DiscordWebSocket.send = ProductionDiscordWebSocket.send # type: ignore + discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(proxy_url) # type: ignore + discord.gateway.ReconnectWebSocket.__init__ = ( # type: ignore + ProductionReconnectWebSocket.__init__ + ) + BallsDexBot.is_ws_ratelimited = is_ws_ratelimited + BallsDexBot.before_identify_hook = before_identify_hook + + +async def shutdown_handler(bot: BallsDexBot, signal_type: str | None = None): + if signal_type: + log.info(f"Received {signal_type}, stopping the bot...") + else: + log.info("Shutting down the bot...") + try: + await asyncio.wait_for(bot.close(), timeout=10) + finally: + pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + [task.cancel() for task in pending] + try: + await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), timeout=5) + except asyncio.TimeoutError: + log.error( + f"Timed out cancelling tasks. {len([t for t in pending if not t.cancelled])}/" + f"{len(pending)} tasks are still pending!" + ) + sys.exit(0 if signal_type else 1) + + +def global_exception_handler(bot: BallsDexBot, loop: asyncio.AbstractEventLoop, context: dict): + """ + Logs unhandled exceptions in other tasks + """ + exc = context.get("exception") + # These will get handled later when it *also* kills loop.run_forever + if exc is not None and isinstance(exc, (KeyboardInterrupt, SystemExit)): + return + log.critical( + "Caught unhandled exception in %s:\n%s", + context.get("future", "event loop"), + context["message"], + exc_info=exc, + ) + + +def bot_exception_handler(bot: BallsDexBot, bot_task: asyncio.Future): + """ + This is set as a done callback for the bot + + Must be used with functools.partial + + If the main bot.run dies for some reason, + we don't want to swallow the exception and hang. + """ + try: + bot_task.result() + except (SystemExit, KeyboardInterrupt, asyncio.CancelledError): + pass # Handled by the global_exception_handler, or cancellation + except Exception as exc: + log.critical("The main bot task didn't handle an exception and has crashed", exc_info=exc) + log.warning("Attempting to die as gracefully as possible...") + asyncio.create_task(shutdown_handler(bot)) + + +class RemoveWSBehindMsg(logging.Filter): + """Filter used when gateway proxy is set, the "behind" message is meaningless in this case.""" + + def __init__(self): + super().__init__(name="discord.gateway") + + def filter(self, record): + if record.levelname == "WARNING" and "Can't keep up" in record.msg: + return False + + return True + + +async def init_tortoise(db_url: str): + log.debug(f"Database URL: {db_url}") + await Tortoise.init(config=TORTOISE_ORM) + + # migrations + command = Command(TORTOISE_ORM, app="models") + await command.init() + migrations = await command.upgrade() + if migrations: + log.info(f"Ran {len(migrations)} migrations: {', '.join(migrations)}") + + +def main(): + bot = None + server = None + cli_flags = parse_cli_flags(sys.argv[1:]) + if cli_flags.version: + print(f"BallsDex Discord bot - {bot_version}") + sys.exit(0) + if cli_flags.reset_settings: + print("[yellow]Resetting configuration file.[/yellow]") + reset_settings(cli_flags.config_file) + + try: + read_settings(cli_flags.config_file) + except FileNotFoundError: + print("[yellow]The config file could not be found, generating a default one.[/yellow]") + reset_settings(cli_flags.config_file) + else: + update_settings(cli_flags.config_file) + + print_welcome() + queue_listener: logging.handlers.QueueListener | None = None + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + queue_listener = init_logger(cli_flags.disable_rich, cli_flags.debug) + + token = settings.bot_token + if not token: + log.error("Token not found!") + print("[red]You must provide a token inside the config.yml file.[/red]") + time.sleep(1) + sys.exit(0) + + db_url = os.environ.get("BALLSDEXBOT_DB_URL", None) + if not db_url: + log.error("Database URL not found!") + print("[red]You must provide a DB URL with the BALLSDEXBOT_DB_URL env var.[/red]") + time.sleep(1) + sys.exit(0) + + if settings.gateway_url is not None: + log.info("Using custom gateway URL: %s", settings.gateway_url) + patch_gateway(settings.gateway_url) + logging.getLogger("discord.gateway").addFilter(RemoveWSBehindMsg()) + + prefix = settings.prefix + + try: + loop.run_until_complete(init_tortoise(db_url)) + except Exception: + log.exception("Failed to connect to database.") + return # will exit with code 1 + log.info("Tortoise ORM and database ready.") + + bot = BallsDexBot( + command_prefix=when_mentioned_or(prefix), + dev=cli_flags.dev, # type: ignore + shard_count=settings.shard_count, + ) + + exc_handler = functools.partial(global_exception_handler, bot) + loop.set_exception_handler(exc_handler) + loop.add_signal_handler( + SIGTERM, lambda: loop.create_task(shutdown_handler(bot, "SIGTERM")) + ) + + log.info("Initialized bot, connecting to Discord...") + future = loop.create_task(bot.start(token)) + bot_exc_handler = functools.partial(bot_exception_handler, bot) + future.add_done_callback(bot_exc_handler) + + loop.run_forever() + except KeyboardInterrupt: + if bot is not None: + loop.run_until_complete(shutdown_handler(bot, "Ctrl+C")) + except Exception: + log.critical("Unhandled exception.", exc_info=True) + if bot is not None: + loop.run_until_complete(shutdown_handler(bot)) + finally: + if queue_listener: + queue_listener.stop() + loop.run_until_complete(loop.shutdown_asyncgens()) + if server is not None: + loop.run_until_complete(server.stop()) + if Tortoise._inited: + loop.run_until_complete(Tortoise.close_connections()) + asyncio.set_event_loop(None) + loop.stop() + loop.close() + sys.exit(bot._shutdown if bot else 1) + + +if __name__ == "__main__": + main() diff --git a/ballsdex/core/bot.py b/ballsdex/core/bot.py index f2a858b3..a7f977e7 100755 --- a/ballsdex/core/bot.py +++ b/ballsdex/core/bot.py @@ -1,468 +1,468 @@ -from __future__ import annotations - -import asyncio -import inspect -import logging -import math -import time -import types -from datetime import datetime -from typing import TYPE_CHECKING, cast - -import aiohttp -import discord -import discord.gateway -from aiohttp import ClientTimeout -from cachetools import TTLCache -from discord import app_commands -from discord.app_commands.translator import TranslationContextTypes, locale_str -from discord.enums import Locale -from discord.ext import commands -from prometheus_client import Histogram -from rich import box, print -from rich.console import Console -from rich.table import Table - -from ballsdex.core.commands import Core -from ballsdex.core.dev import Dev -from ballsdex.core.metrics import PrometheusServer -from ballsdex.core.models import ( - Ball, - BlacklistedGuild, - BlacklistedID, - Economy, - Regime, - Special, - balls, - economies, - regimes, - specials, -) -from ballsdex.settings import settings - -if TYPE_CHECKING: - from discord.ext.commands.bot import PrefixType - -log = logging.getLogger("ballsdex.core.bot") -http_counter = Histogram("discord_http_requests", "HTTP requests", ["key", "code"]) - -PACKAGES = ["config", "players", "countryballs", "info", "admin", "trade", "balls"] - - -def owner_check(ctx: commands.Context[BallsDexBot]): - return ctx.bot.is_owner(ctx.author) - - -class Translator(app_commands.Translator): - async def translate( - self, string: locale_str, locale: Locale, context: TranslationContextTypes - ) -> str | None: - return string.message.replace("countryball", settings.collectible_name).replace( - "BallsDex", settings.bot_name - ) - - -# observing the duration and status code of HTTP requests through aiohttp TraceConfig -async def on_request_start( - session: aiohttp.ClientSession, - trace_ctx: types.SimpleNamespace, - params: aiohttp.TraceRequestStartParams, -): - # register t1 before sending request - trace_ctx.start = session.loop.time() - - -async def on_request_end( - session: aiohttp.ClientSession, - trace_ctx: types.SimpleNamespace, - params: aiohttp.TraceRequestEndParams, -): - time = session.loop.time() - trace_ctx.start - - # to categorize HTTP calls per path, we need to access the corresponding discord.http.Route - # object, which is not available in the context of an aiohttp TraceConfig, therefore it's - # obtained by accessing the locals() from the calling function HTTPConfig.request - # "params.url.path" is not usable as it contains raw IDs and tokens, breaking categories - frame = inspect.currentframe() - _locals = frame.f_back.f_back.f_back.f_back.f_back.f_locals # type: ignore - if route := _locals.get("route"): - route_key = route.key - else: - # calling function is HTTPConfig.static_login which has no Route object - route_key = f"{params.response.method} {params.url.path}" - - http_counter.labels(route_key, params.response.status).observe(time) - - -class CommandTree(app_commands.CommandTree): - async def interaction_check(self, interaction: discord.Interaction[BallsDexBot], /) -> bool: - # checking if the moment we receive this interaction isn't too late already - # there is a 3 seconds limit for initial response, taking a little margin into account - # https://discord.com/developers/docs/interactions/receiving-and-responding#responding-to-an-interaction - delta = datetime.now(tz=interaction.created_at.tzinfo) - interaction.created_at - if delta.total_seconds() >= 2.8: - log.warning( - f"Skipping interaction {interaction.id}, running {delta.total_seconds()}s late." - ) - return False - - bot = interaction.client - if not bot.is_ready(): - if interaction.type != discord.InteractionType.autocomplete: - await interaction.response.send_message( - "The bot is currently starting, please wait for a few minutes... " - f"({round((len(bot.shards) / bot.shard_count) * 100)}%)", - ephemeral=True, - ) - return False # wait for all shards to be connected - return await bot.blacklist_check(interaction) - - -class BallsDexBot(commands.AutoShardedBot): - """ - BallsDex Discord bot - """ - - def __init__(self, command_prefix: PrefixType[BallsDexBot], dev: bool = False, **options): - # An explaination for the used intents - # guilds: needed for basically anything, the bot needs to know what guilds it has - # and accordingly enable automatic spawning in the enabled ones - # guild_messages: spawning is based on messages sent, content is not necessary - # emojis_and_stickers: DB holds emoji IDs for the balls which are fetched from 3 servers - intents = discord.Intents( - guilds=True, guild_messages=True, emojis_and_stickers=True, message_content=True - ) - - if settings.prometheus_enabled: - trace = aiohttp.TraceConfig() - trace.on_request_start.append(on_request_start) - trace.on_request_end.append(on_request_end) - options["http_trace"] = trace - - super().__init__(command_prefix, intents=intents, tree_cls=CommandTree, **options) - - self.dev = dev - self.prometheus_server: PrometheusServer | None = None - - self.tree.error(self.on_application_command_error) - self.add_check(owner_check) # Only owners are able to use text commands - - self._shutdown = 0 - self.blacklist: set[int] = set() - self.blacklist_guild: set[int] = set() - self.catch_log: set[int] = set() - self.command_log: set[int] = set() - self.locked_balls = TTLCache(maxsize=99999, ttl=60 * 30) - - self.owner_ids: set - - async def start_prometheus_server(self): - self.prometheus_server = PrometheusServer( - self, settings.prometheus_host, settings.prometheus_port - ) - await self.prometheus_server.run() - - def assign_ids_to_app_groups( - self, group: app_commands.Group, synced_commands: list[app_commands.AppCommandGroup] - ): - for synced_command in synced_commands: - bot_command = group.get_command(synced_command.name) - if not bot_command: - continue - bot_command.extras["mention"] = synced_command.mention - if isinstance(bot_command, app_commands.Group) and bot_command.commands: - self.assign_ids_to_app_groups( - bot_command, cast(list[app_commands.AppCommandGroup], synced_command.options) - ) - - def assign_ids_to_app_commands(self, synced_commands: list[app_commands.AppCommand]): - for synced_command in synced_commands: - bot_command = self.tree.get_command(synced_command.name, type=synced_command.type) - if not bot_command: - continue - bot_command.extras["mention"] = synced_command.mention - if isinstance(bot_command, app_commands.Group) and bot_command.commands: - self.assign_ids_to_app_groups( - bot_command, cast(list[app_commands.AppCommandGroup], synced_command.options) - ) - - async def load_cache(self): - table = Table(box=box.SIMPLE) - table.add_column("Model", style="cyan") - table.add_column("Count", justify="right", style="green") - - balls.clear() - for ball in await Ball.all(): - balls[ball.pk] = ball - table.add_row(settings.collectible_name.title() + "s", str(len(balls))) - - regimes.clear() - for regime in await Regime.all(): - regimes[regime.pk] = regime - table.add_row("Regimes", str(len(regimes))) - - economies.clear() - for economy in await Economy.all(): - economies[economy.pk] = economy - table.add_row("Economies", str(len(economies))) - - specials.clear() - for special in await Special.all(): - specials[special.pk] = special - table.add_row("Special events", str(len(specials))) - - self.blacklist = set() - for blacklisted_id in await BlacklistedID.all().only("discord_id"): - self.blacklist.add(blacklisted_id.discord_id) - table.add_row("Blacklisted users", str(len(self.blacklist))) - - self.blacklist_guild = set() - for blacklisted_id in await BlacklistedGuild.all().only("discord_id"): - self.blacklist_guild.add(blacklisted_id.discord_id) - table.add_row("Blacklisted guilds", str(len(self.blacklist_guild))) - - log.info("Cache loaded, summary displayed below") - console = Console() - console.print(table) - - async def gateway_healthy(self) -> bool: - """Check whether or not the gateway proxy is ready and healthy.""" - if settings.gateway_url is None: - raise RuntimeError("This is only available on the production bot instance.") - - try: - base_url = str(discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY).replace( - "ws://", "http://" - ) - async with aiohttp.ClientSession() as session: - async with session.get( - f"{base_url}/health", timeout=ClientTimeout(total=10) - ) as resp: - return resp.status == 200 - except (aiohttp.ClientConnectionError, asyncio.TimeoutError): - return False - - async def setup_hook(self) -> None: - await self.tree.set_translator(Translator()) - log.info("Starting up with %s shards...", self.shard_count) - if settings.gateway_url is None: - return - - while True: - response = await self.gateway_healthy() - if response is True: - log.info("Gateway proxy is ready!") - break - - log.warning("Gateway proxy is not ready yet, waiting 30 more seconds...") - await asyncio.sleep(30) - - async def on_ready(self): - if self.cogs != {}: - return # bot is reconnecting, no need to setup again - - assert self.user - log.info(f"Successfully logged in as {self.user} ({self.user.id})!") - - # set bot owners - assert self.application - if self.application.team: - if settings.team_owners: - self.owner_ids.update(m.id for m in self.application.team.members) - else: - self.owner_ids.add(self.application.team.owner_id) - else: - self.owner_ids.add(self.application.owner.id) - if settings.co_owners: - self.owner_ids.update(settings.co_owners) - if len(self.owner_ids) > 1: - log.info(f"{len(self.owner_ids)} users are set as bot owner.") - else: - log.info( - f"{await self.fetch_user(next(iter(self.owner_ids)))} is the owner of this bot." - ) - - await self.load_cache() - if self.blacklist: - log.info(f"{len(self.blacklist)} blacklisted users.") - - log.info("Loading packages...") - await self.add_cog(Core(self)) - if self.dev: - await self.add_cog(Dev()) - - loaded_packages = [] - for package in PACKAGES: - try: - await self.load_extension("ballsdex.packages." + package) - except Exception: - log.error(f"Failed to load package {package}", exc_info=True) - else: - loaded_packages.append(package) - if loaded_packages: - log.info(f"Packages loaded: {', '.join(loaded_packages)}") - else: - log.info("No package loaded.") - - synced_commands = await self.tree.sync() - if synced_commands: - log.info(f"Synced {len(synced_commands)} commands.") - try: - self.assign_ids_to_app_commands(synced_commands) - except Exception: - log.error("Failed to assign IDs to app commands", exc_info=True) - else: - log.info("No command to sync.") - - if "admin" in PACKAGES: - for guild_id in settings.admin_guild_ids: - guild = self.get_guild(guild_id) - if not guild: - continue - synced_commands = await self.tree.sync(guild=guild) - log.info(f"Synced {len(synced_commands)} admin commands for guild {guild.id}.") - - if settings.prometheus_enabled: - try: - await self.start_prometheus_server() - except Exception: - log.exception("Failed to start Prometheus server, stats will be unavailable.") - - print( - f"\n [bold][red]{settings.bot_name} bot[/red] [green]" - "is now operational![/green][/bold]\n" - ) - - async def blacklist_check(self, interaction: discord.Interaction) -> bool: - if interaction.user.id in self.blacklist: - if interaction.type != discord.InteractionType.autocomplete: - await interaction.response.send_message( - "You are blacklisted from the bot." - "\nYou can appeal this blacklist in our support server: {}".format( - settings.discord_invite - ), - ephemeral=True, - ) - return False - if interaction.guild_id and interaction.guild_id in self.blacklist_guild: - if interaction.type != discord.InteractionType.autocomplete: - await interaction.response.send_message( - "This server is blacklisted from the bot." - "\nYou can appeal this blacklist in our support server: {}".format( - settings.discord_invite - ), - ephemeral=True, - ) - return False - if interaction.command and interaction.user.id in self.command_log: - log.info( - f'{interaction.user} ({interaction.user.id}) used "{interaction.command.name}" in ' - f"{interaction.guild} ({interaction.guild_id})" - ) - return True - - async def on_command_error( - self, context: commands.Context, exception: commands.errors.CommandError - ): - if isinstance( - exception, (commands.CommandNotFound, commands.CheckFailure, commands.DisabledCommand) - ): - return - - assert context.command - if isinstance(exception, (commands.ConversionError, commands.UserInputError)): - # in case we need to know what happened - log.debug("Silenced command exception", exc_info=exception) - await context.send_help(context.command) - return - - if isinstance(exception, commands.MissingRequiredAttachment): - await context.send("An attachment is missing.") - return - - if isinstance(exception, commands.CommandInvokeError): - if isinstance(exception.original, discord.Forbidden): - await context.send("The bot does not have the permission to do something.") - # log to know where permissions are lacking - log.warning( - f"Missing permissions for text command {context.command.name}", - exc_info=exception.original, - ) - return - - log.error(f"Error in text command {context.command.name}", exc_info=exception.original) - await context.send( - "An error occured when running the command. Contact support if this persists." - ) - return - - await context.send( - "An error occured when running the command. Contact support if this persists." - ) - log.error(f"Unknown error in text command {context.command.name}", exc_info=exception) - - async def on_application_command_error( - self, interaction: discord.Interaction, error: app_commands.AppCommandError - ): - async def send(content: str): - if interaction.response.is_done(): - await interaction.followup.send(content, ephemeral=True) - else: - await interaction.response.send_message(content, ephemeral=True) - - if isinstance(error, app_commands.CheckFailure): - if isinstance(error, app_commands.CommandOnCooldown): - await send( - "This command is on cooldown. Please retry " - f"." - ) - return - await send("You are not allowed to use that command.") - return - - if isinstance(error, app_commands.TransformerError): - await send("One of the arguments provided cannot be parsed.") - log.debug("Failed running converter", exc_info=error) - return - - if isinstance(error, app_commands.CommandInvokeError): - assert interaction.command - - if isinstance(error.original, discord.Forbidden): - await send("The bot does not have the permission to do something.") - # log to know where permissions are lacking - log.warning( - f"Missing permissions for app command {interaction.command.name}", - exc_info=error.original, - ) - return - - if isinstance(error.original, discord.InteractionResponded): - # most likely an interaction received twice (happens sometimes), - # or two instances are running on the same token. - log.warning( - f"Tried invoking command {interaction.command.name}, but the " - "interaction was already responded to.", - exc_info=error.original, - ) - # still including traceback because it may be a programming error - - log.error( - f"Error in slash command {interaction.command.name}", exc_info=error.original - ) - await send( - "An error occured when running the command. Contact support if this persists." - ) - return - - await send("An error occured when running the command. Contact support if this persists.") - log.error("Unknown error in interaction", exc_info=error) - - async def on_error(self, event_method: str, /, *args, **kwargs): - formatted_args = ", ".join(args) - formatted_kwargs = " ".join(f"{x}={y}" for x, y in kwargs.items()) - log.error( - f"Error in event {event_method}. Args: {formatted_args}. Kwargs: {formatted_kwargs}", - exc_info=True, - ) - self.tree.interaction_check +from __future__ import annotations + +import asyncio +import inspect +import logging +import math +import time +import types +from datetime import datetime +from typing import TYPE_CHECKING, cast + +import aiohttp +import discord +import discord.gateway +from aiohttp import ClientTimeout +from cachetools import TTLCache +from discord import app_commands +from discord.app_commands.translator import TranslationContextTypes, locale_str +from discord.enums import Locale +from discord.ext import commands +from prometheus_client import Histogram +from rich import box, print +from rich.console import Console +from rich.table import Table + +from ballsdex.core.commands import Core +from ballsdex.core.dev import Dev +from ballsdex.core.metrics import PrometheusServer +from ballsdex.core.models import ( + Ball, + BlacklistedGuild, + BlacklistedID, + Economy, + Regime, + Special, + balls, + economies, + regimes, + specials, +) +from ballsdex.settings import settings + +if TYPE_CHECKING: + from discord.ext.commands.bot import PrefixType + +log = logging.getLogger("ballsdex.core.bot") +http_counter = Histogram("discord_http_requests", "HTTP requests", ["key", "code"]) + +PACKAGES = ["config", "players", "countryballs", "info", "admin", "trade", "balls"] + + +def owner_check(ctx: commands.Context[BallsDexBot]): + return ctx.bot.is_owner(ctx.author) + + +class Translator(app_commands.Translator): + async def translate( + self, string: locale_str, locale: Locale, context: TranslationContextTypes + ) -> str | None: + return string.message.replace("countryball", settings.collectible_name).replace( + "BallsDex", settings.bot_name + ) + + +# observing the duration and status code of HTTP requests through aiohttp TraceConfig +async def on_request_start( + session: aiohttp.ClientSession, + trace_ctx: types.SimpleNamespace, + params: aiohttp.TraceRequestStartParams, +): + # register t1 before sending request + trace_ctx.start = session.loop.time() + + +async def on_request_end( + session: aiohttp.ClientSession, + trace_ctx: types.SimpleNamespace, + params: aiohttp.TraceRequestEndParams, +): + time = session.loop.time() - trace_ctx.start + + # to categorize HTTP calls per path, we need to access the corresponding discord.http.Route + # object, which is not available in the context of an aiohttp TraceConfig, therefore it's + # obtained by accessing the locals() from the calling function HTTPConfig.request + # "params.url.path" is not usable as it contains raw IDs and tokens, breaking categories + frame = inspect.currentframe() + _locals = frame.f_back.f_back.f_back.f_back.f_back.f_locals # type: ignore + if route := _locals.get("route"): + route_key = route.key + else: + # calling function is HTTPConfig.static_login which has no Route object + route_key = f"{params.response.method} {params.url.path}" + + http_counter.labels(route_key, params.response.status).observe(time) + + +class CommandTree(app_commands.CommandTree): + async def interaction_check(self, interaction: discord.Interaction[BallsDexBot], /) -> bool: + # checking if the moment we receive this interaction isn't too late already + # there is a 3 seconds limit for initial response, taking a little margin into account + # https://discord.com/developers/docs/interactions/receiving-and-responding#responding-to-an-interaction + delta = datetime.now(tz=interaction.created_at.tzinfo) - interaction.created_at + if delta.total_seconds() >= 2.8: + log.warning( + f"Skipping interaction {interaction.id}, running {delta.total_seconds()}s late." + ) + return False + + bot = interaction.client + if not bot.is_ready(): + if interaction.type != discord.InteractionType.autocomplete: + await interaction.response.send_message( + "The bot is currently starting, please wait for a few minutes... " + f"({round((len(bot.shards) / bot.shard_count) * 100)}%)", + ephemeral=True, + ) + return False # wait for all shards to be connected + return await bot.blacklist_check(interaction) + + +class BallsDexBot(commands.AutoShardedBot): + """ + BallsDex Discord bot + """ + + def __init__(self, command_prefix: PrefixType[BallsDexBot], dev: bool = False, **options): + # An explaination for the used intents + # guilds: needed for basically anything, the bot needs to know what guilds it has + # and accordingly enable automatic spawning in the enabled ones + # guild_messages: spawning is based on messages sent, content is not necessary + # emojis_and_stickers: DB holds emoji IDs for the balls which are fetched from 3 servers + intents = discord.Intents( + guilds=True, guild_messages=True, emojis_and_stickers=True, message_content=True + ) + + if settings.prometheus_enabled: + trace = aiohttp.TraceConfig() + trace.on_request_start.append(on_request_start) + trace.on_request_end.append(on_request_end) + options["http_trace"] = trace + + super().__init__(command_prefix, intents=intents, tree_cls=CommandTree, **options) + + self.dev = dev + self.prometheus_server: PrometheusServer | None = None + + self.tree.error(self.on_application_command_error) + self.add_check(owner_check) # Only owners are able to use text commands + + self._shutdown = 0 + self.blacklist: set[int] = set() + self.blacklist_guild: set[int] = set() + self.catch_log: set[int] = set() + self.command_log: set[int] = set() + self.locked_balls = TTLCache(maxsize=99999, ttl=60 * 30) + + self.owner_ids: set + + async def start_prometheus_server(self): + self.prometheus_server = PrometheusServer( + self, settings.prometheus_host, settings.prometheus_port + ) + await self.prometheus_server.run() + + def assign_ids_to_app_groups( + self, group: app_commands.Group, synced_commands: list[app_commands.AppCommandGroup] + ): + for synced_command in synced_commands: + bot_command = group.get_command(synced_command.name) + if not bot_command: + continue + bot_command.extras["mention"] = synced_command.mention + if isinstance(bot_command, app_commands.Group) and bot_command.commands: + self.assign_ids_to_app_groups( + bot_command, cast(list[app_commands.AppCommandGroup], synced_command.options) + ) + + def assign_ids_to_app_commands(self, synced_commands: list[app_commands.AppCommand]): + for synced_command in synced_commands: + bot_command = self.tree.get_command(synced_command.name, type=synced_command.type) + if not bot_command: + continue + bot_command.extras["mention"] = synced_command.mention + if isinstance(bot_command, app_commands.Group) and bot_command.commands: + self.assign_ids_to_app_groups( + bot_command, cast(list[app_commands.AppCommandGroup], synced_command.options) + ) + + async def load_cache(self): + table = Table(box=box.SIMPLE) + table.add_column("Model", style="cyan") + table.add_column("Count", justify="right", style="green") + + balls.clear() + for ball in await Ball.all(): + balls[ball.pk] = ball + table.add_row(settings.collectible_name.title() + "s", str(len(balls))) + + regimes.clear() + for regime in await Regime.all(): + regimes[regime.pk] = regime + table.add_row("Regimes", str(len(regimes))) + + economies.clear() + for economy in await Economy.all(): + economies[economy.pk] = economy + table.add_row("Economies", str(len(economies))) + + specials.clear() + for special in await Special.all(): + specials[special.pk] = special + table.add_row("Special events", str(len(specials))) + + self.blacklist = set() + for blacklisted_id in await BlacklistedID.all().only("discord_id"): + self.blacklist.add(blacklisted_id.discord_id) + table.add_row("Blacklisted users", str(len(self.blacklist))) + + self.blacklist_guild = set() + for blacklisted_id in await BlacklistedGuild.all().only("discord_id"): + self.blacklist_guild.add(blacklisted_id.discord_id) + table.add_row("Blacklisted guilds", str(len(self.blacklist_guild))) + + log.info("Cache loaded, summary displayed below") + console = Console() + console.print(table) + + async def gateway_healthy(self) -> bool: + """Check whether or not the gateway proxy is ready and healthy.""" + if settings.gateway_url is None: + raise RuntimeError("This is only available on the production bot instance.") + + try: + base_url = str(discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY).replace( + "ws://", "http://" + ) + async with aiohttp.ClientSession() as session: + async with session.get( + f"{base_url}/health", timeout=ClientTimeout(total=10) + ) as resp: + return resp.status == 200 + except (aiohttp.ClientConnectionError, asyncio.TimeoutError): + return False + + async def setup_hook(self) -> None: + await self.tree.set_translator(Translator()) + log.info("Starting up with %s shards...", self.shard_count) + if settings.gateway_url is None: + return + + while True: + response = await self.gateway_healthy() + if response is True: + log.info("Gateway proxy is ready!") + break + + log.warning("Gateway proxy is not ready yet, waiting 30 more seconds...") + await asyncio.sleep(30) + + async def on_ready(self): + if self.cogs != {}: + return # bot is reconnecting, no need to setup again + + assert self.user + log.info(f"Successfully logged in as {self.user} ({self.user.id})!") + + # set bot owners + assert self.application + if self.application.team: + if settings.team_owners: + self.owner_ids.update(m.id for m in self.application.team.members) + else: + self.owner_ids.add(self.application.team.owner_id) + else: + self.owner_ids.add(self.application.owner.id) + if settings.co_owners: + self.owner_ids.update(settings.co_owners) + if len(self.owner_ids) > 1: + log.info(f"{len(self.owner_ids)} users are set as bot owner.") + else: + log.info( + f"{await self.fetch_user(next(iter(self.owner_ids)))} is the owner of this bot." + ) + + await self.load_cache() + if self.blacklist: + log.info(f"{len(self.blacklist)} blacklisted users.") + + log.info("Loading packages...") + await self.add_cog(Core(self)) + if self.dev: + await self.add_cog(Dev()) + + loaded_packages = [] + for package in PACKAGES: + try: + await self.load_extension("ballsdex.packages." + package) + except Exception: + log.error(f"Failed to load package {package}", exc_info=True) + else: + loaded_packages.append(package) + if loaded_packages: + log.info(f"Packages loaded: {', '.join(loaded_packages)}") + else: + log.info("No package loaded.") + + synced_commands = await self.tree.sync() + if synced_commands: + log.info(f"Synced {len(synced_commands)} commands.") + try: + self.assign_ids_to_app_commands(synced_commands) + except Exception: + log.error("Failed to assign IDs to app commands", exc_info=True) + else: + log.info("No command to sync.") + + if "admin" in PACKAGES: + for guild_id in settings.admin_guild_ids: + guild = self.get_guild(guild_id) + if not guild: + continue + synced_commands = await self.tree.sync(guild=guild) + log.info(f"Synced {len(synced_commands)} admin commands for guild {guild.id}.") + + if settings.prometheus_enabled: + try: + await self.start_prometheus_server() + except Exception: + log.exception("Failed to start Prometheus server, stats will be unavailable.") + + print( + f"\n [bold][red]{settings.bot_name} bot[/red] [green]" + "is now operational![/green][/bold]\n" + ) + + async def blacklist_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id in self.blacklist: + if interaction.type != discord.InteractionType.autocomplete: + await interaction.response.send_message( + "You are blacklisted from the bot." + "\nYou can appeal this blacklist in our support server: {}".format( + settings.discord_invite + ), + ephemeral=True, + ) + return False + if interaction.guild_id and interaction.guild_id in self.blacklist_guild: + if interaction.type != discord.InteractionType.autocomplete: + await interaction.response.send_message( + "This server is blacklisted from the bot." + "\nYou can appeal this blacklist in our support server: {}".format( + settings.discord_invite + ), + ephemeral=True, + ) + return False + if interaction.command and interaction.user.id in self.command_log: + log.info( + f'{interaction.user} ({interaction.user.id}) used "{interaction.command.name}" in ' + f"{interaction.guild} ({interaction.guild_id})" + ) + return True + + async def on_command_error( + self, context: commands.Context, exception: commands.errors.CommandError + ): + if isinstance( + exception, (commands.CommandNotFound, commands.CheckFailure, commands.DisabledCommand) + ): + return + + assert context.command + if isinstance(exception, (commands.ConversionError, commands.UserInputError)): + # in case we need to know what happened + log.debug("Silenced command exception", exc_info=exception) + await context.send_help(context.command) + return + + if isinstance(exception, commands.MissingRequiredAttachment): + await context.send("An attachment is missing.") + return + + if isinstance(exception, commands.CommandInvokeError): + if isinstance(exception.original, discord.Forbidden): + await context.send("The bot does not have the permission to do something.") + # log to know where permissions are lacking + log.warning( + f"Missing permissions for text command {context.command.name}", + exc_info=exception.original, + ) + return + + log.error(f"Error in text command {context.command.name}", exc_info=exception.original) + await context.send( + "An error occured when running the command. Contact support if this persists." + ) + return + + await context.send( + "An error occured when running the command. Contact support if this persists." + ) + log.error(f"Unknown error in text command {context.command.name}", exc_info=exception) + + async def on_application_command_error( + self, interaction: discord.Interaction, error: app_commands.AppCommandError + ): + async def send(content: str): + if interaction.response.is_done(): + await interaction.followup.send(content, ephemeral=True) + else: + await interaction.response.send_message(content, ephemeral=True) + + if isinstance(error, app_commands.CheckFailure): + if isinstance(error, app_commands.CommandOnCooldown): + await send( + "This command is on cooldown. Please retry " + f"." + ) + return + await send("You are not allowed to use that command.") + return + + if isinstance(error, app_commands.TransformerError): + await send("One of the arguments provided cannot be parsed.") + log.debug("Failed running converter", exc_info=error) + return + + if isinstance(error, app_commands.CommandInvokeError): + assert interaction.command + + if isinstance(error.original, discord.Forbidden): + await send("The bot does not have the permission to do something.") + # log to know where permissions are lacking + log.warning( + f"Missing permissions for app command {interaction.command.name}", + exc_info=error.original, + ) + return + + if isinstance(error.original, discord.InteractionResponded): + # most likely an interaction received twice (happens sometimes), + # or two instances are running on the same token. + log.warning( + f"Tried invoking command {interaction.command.name}, but the " + "interaction was already responded to.", + exc_info=error.original, + ) + # still including traceback because it may be a programming error + + log.error( + f"Error in slash command {interaction.command.name}", exc_info=error.original + ) + await send( + "An error occured when running the command. Contact support if this persists." + ) + return + + await send("An error occured when running the command. Contact support if this persists.") + log.error("Unknown error in interaction", exc_info=error) + + async def on_error(self, event_method: str, /, *args, **kwargs): + formatted_args = ", ".join(args) + formatted_kwargs = " ".join(f"{x}={y}" for x, y in kwargs.items()) + log.error( + f"Error in event {event_method}. Args: {formatted_args}. Kwargs: {formatted_kwargs}", + exc_info=True, + ) + self.tree.interaction_check diff --git a/ballsdex/core/commands.py b/ballsdex/core/commands.py index b0df0ad4..70a46af6 100755 --- a/ballsdex/core/commands.py +++ b/ballsdex/core/commands.py @@ -1,80 +1,80 @@ -import logging -import time -from typing import TYPE_CHECKING - -from discord.ext import commands -from tortoise import Tortoise - -log = logging.getLogger("ballsdex.core.commands") - -if TYPE_CHECKING: - from .bot import BallsDexBot - - -class Core(commands.Cog): - """ - Core commands of BallsDex bot - """ - - def __init__(self, bot: "BallsDexBot"): - self.bot = bot - - @commands.command() - async def ping(self, ctx: commands.Context): - """ - Ping! - """ - await ctx.send("Pong") - - @commands.command() - @commands.is_owner() - async def reloadtree(self, ctx: commands.Context): - """ - Sync the application commands with Discord - """ - await self.bot.tree.sync() - await ctx.send("Application commands tree reloaded.") - - @commands.command() - @commands.is_owner() - async def reload(self, ctx: commands.Context, package: str): - """ - Reload an extension - """ - package = "ballsdex.packages." + package - try: - try: - await self.bot.reload_extension(package) - except commands.ExtensionNotLoaded: - await self.bot.load_extension(package) - except commands.ExtensionNotFound: - await ctx.send("Extension not found") - except Exception: - await ctx.send("Failed to reload extension.") - log.error(f"Failed to reload extension {package}", exc_info=True) - else: - await ctx.send("Extension reloaded.") - - @commands.command() - @commands.is_owner() - async def reloadcache(self, ctx: commands.Context): - """ - Reload the cache of database models. - - This is needed each time the database is updated, otherwise changes won't reflect until - next start. - """ - await self.bot.load_cache() - await ctx.message.add_reaction("✅") - - @commands.command() - @commands.is_owner() - async def analyzedb(self, ctx: commands.Context): - """ - Analyze the database. This refreshes the counts displayed by the `/about` command. - """ - connection = Tortoise.get_connection("default") - t1 = time.time() - await connection.execute_query("ANALYZE") - t2 = time.time() - await ctx.send(f"Analyzed database in {round((t2 - t1) * 1000)}ms.") +import logging +import time +from typing import TYPE_CHECKING + +from discord.ext import commands +from tortoise import Tortoise + +log = logging.getLogger("ballsdex.core.commands") + +if TYPE_CHECKING: + from .bot import BallsDexBot + + +class Core(commands.Cog): + """ + Core commands of BallsDex bot + """ + + def __init__(self, bot: "BallsDexBot"): + self.bot = bot + + @commands.command() + async def ping(self, ctx: commands.Context): + """ + Ping! + """ + await ctx.send("Pong") + + @commands.command() + @commands.is_owner() + async def reloadtree(self, ctx: commands.Context): + """ + Sync the application commands with Discord + """ + await self.bot.tree.sync() + await ctx.send("Application commands tree reloaded.") + + @commands.command() + @commands.is_owner() + async def reload(self, ctx: commands.Context, package: str): + """ + Reload an extension + """ + package = "ballsdex.packages." + package + try: + try: + await self.bot.reload_extension(package) + except commands.ExtensionNotLoaded: + await self.bot.load_extension(package) + except commands.ExtensionNotFound: + await ctx.send("Extension not found") + except Exception: + await ctx.send("Failed to reload extension.") + log.error(f"Failed to reload extension {package}", exc_info=True) + else: + await ctx.send("Extension reloaded.") + + @commands.command() + @commands.is_owner() + async def reloadcache(self, ctx: commands.Context): + """ + Reload the cache of database models. + + This is needed each time the database is updated, otherwise changes won't reflect until + next start. + """ + await self.bot.load_cache() + await ctx.message.add_reaction("✅") + + @commands.command() + @commands.is_owner() + async def analyzedb(self, ctx: commands.Context): + """ + Analyze the database. This refreshes the counts displayed by the `/about` command. + """ + connection = Tortoise.get_connection("default") + t1 = time.time() + await connection.execute_query("ANALYZE") + t2 = time.time() + await ctx.send(f"Analyzed database in {round((t2 - t1) * 1000)}ms.") diff --git a/ballsdex/core/image_generator/image_gen.py b/ballsdex/core/image_generator/image_gen.py index 4ed1e65d..b10264d5 100755 --- a/ballsdex/core/image_generator/image_gen.py +++ b/ballsdex/core/image_generator/image_gen.py @@ -1,107 +1,107 @@ -import os -import textwrap -from pathlib import Path -from typing import TYPE_CHECKING - -from PIL import Image, ImageDraw, ImageFont, ImageOps - -if TYPE_CHECKING: - from ballsdex.core.models import BallInstance - - -SOURCES_PATH = Path(os.path.dirname(os.path.abspath(__file__)), "./src") -WIDTH = 1500 -HEIGHT = 2000 - -RECTANGLE_WIDTH = WIDTH - 40 -RECTANGLE_HEIGHT = (HEIGHT // 5) * 2 - -CORNERS = ((34, 261), (1393, 992)) -artwork_size = [b - a for a, b in zip(*CORNERS)] - -title_font = ImageFont.truetype(str(SOURCES_PATH / "ArsenicaTrial-Extrabold.ttf"), 170) -capacity_name_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 110) -capacity_description_font = ImageFont.truetype(str(SOURCES_PATH / "OpenSans-Semibold.ttf"), 75) -stats_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 130) -credits_font = ImageFont.truetype(str(SOURCES_PATH / "arial.ttf"), 40) - - -def draw_card(ball_instance: "BallInstance"): - ball = ball_instance.countryball - ball_health = (237, 115, 101, 255) - - if ball_instance.shiny: - image = Image.open(str(SOURCES_PATH / "shiny.png")) - ball_health = (255, 255, 255, 255) - elif special_image := ball_instance.special_card: - image = Image.open("." + special_image) - else: - image = Image.open("." + ball.cached_regime.background) - image = image.convert("RGBA") - icon = ( - Image.open("." + ball.cached_economy.icon).convert("RGBA") if ball.cached_economy else None - ) - - draw = ImageDraw.Draw(image) - draw.text( - (50, 20), - ball.short_name or ball.country, - font=title_font, - stroke_width=2, - stroke_fill=(0, 0, 0, 255), - ) - for i, line in enumerate(textwrap.wrap(f"Ability: {ball.capacity_name}", width=26)): - draw.text( - (100, 1050 + 100 * i), - line, - font=capacity_name_font, - fill=(230, 230, 230, 255), - stroke_width=2, - stroke_fill=(0, 0, 0, 255), - ) - for i, line in enumerate(textwrap.wrap(ball.capacity_description, width=32)): - draw.text( - (60, 1300 + 80 * i), - line, - font=capacity_description_font, - stroke_width=1, - stroke_fill=(0, 0, 0, 255), - ) - draw.text( - (320, 1670), - str(ball_instance.health), - font=stats_font, - fill=ball_health, - stroke_width=1, - stroke_fill=(0, 0, 0, 255), - ) - draw.text( - (1120, 1670), - str(ball_instance.attack), - font=stats_font, - fill=(252, 194, 76, 255), - stroke_width=1, - stroke_fill=(0, 0, 0, 255), - anchor="ra", - ) - draw.text( - (30, 1870), - # Modifying the line below is breaking the licence as you are removing credits - # If you don't want to receive a DMCA, just don't - "Created by El Laggron\n" f"Artwork author: {ball.credits}", - font=credits_font, - fill=(0, 0, 0, 255), - stroke_width=0, - stroke_fill=(255, 255, 255, 255), - ) - - artwork = Image.open("." + ball.collection_card).convert("RGBA") - image.paste(ImageOps.fit(artwork, artwork_size), CORNERS[0]) # type: ignore - - if icon: - icon = ImageOps.fit(icon, (192, 192)) - image.paste(icon, (1200, 30), mask=icon) - icon.close() - artwork.close() - - return image +import os +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING + +from PIL import Image, ImageDraw, ImageFont, ImageOps + +if TYPE_CHECKING: + from ballsdex.core.models import BallInstance + + +SOURCES_PATH = Path(os.path.dirname(os.path.abspath(__file__)), "./src") +WIDTH = 1500 +HEIGHT = 2000 + +RECTANGLE_WIDTH = WIDTH - 40 +RECTANGLE_HEIGHT = (HEIGHT // 5) * 2 + +CORNERS = ((34, 261), (1393, 992)) +artwork_size = [b - a for a, b in zip(*CORNERS)] + +title_font = ImageFont.truetype(str(SOURCES_PATH / "ArsenicaTrial-Extrabold.ttf"), 170) +capacity_name_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 110) +capacity_description_font = ImageFont.truetype(str(SOURCES_PATH / "OpenSans-Semibold.ttf"), 75) +stats_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 130) +credits_font = ImageFont.truetype(str(SOURCES_PATH / "arial.ttf"), 40) + + +def draw_card(ball_instance: "BallInstance"): + ball = ball_instance.countryball + ball_health = (237, 115, 101, 255) + + if ball_instance.shiny: + image = Image.open(str(SOURCES_PATH / "shiny.png")) + ball_health = (255, 255, 255, 255) + elif special_image := ball_instance.special_card: + image = Image.open("." + special_image) + else: + image = Image.open("." + ball.cached_regime.background) + image = image.convert("RGBA") + icon = ( + Image.open("." + ball.cached_economy.icon).convert("RGBA") if ball.cached_economy else None + ) + + draw = ImageDraw.Draw(image) + draw.text( + (50, 20), + ball.short_name or ball.country, + font=title_font, + stroke_width=2, + stroke_fill=(0, 0, 0, 255), + ) + for i, line in enumerate(textwrap.wrap(f"Ability: {ball.capacity_name}", width=26)): + draw.text( + (100, 1050 + 100 * i), + line, + font=capacity_name_font, + fill=(230, 230, 230, 255), + stroke_width=2, + stroke_fill=(0, 0, 0, 255), + ) + for i, line in enumerate(textwrap.wrap(ball.capacity_description, width=32)): + draw.text( + (60, 1300 + 80 * i), + line, + font=capacity_description_font, + stroke_width=1, + stroke_fill=(0, 0, 0, 255), + ) + draw.text( + (320, 1670), + str(ball_instance.health), + font=stats_font, + fill=ball_health, + stroke_width=1, + stroke_fill=(0, 0, 0, 255), + ) + draw.text( + (1120, 1670), + str(ball_instance.attack), + font=stats_font, + fill=(252, 194, 76, 255), + stroke_width=1, + stroke_fill=(0, 0, 0, 255), + anchor="ra", + ) + draw.text( + (30, 1870), + # Modifying the line below is breaking the licence as you are removing credits + # If you don't want to receive a DMCA, just don't + "Created by El Laggron\n" f"Artwork author: {ball.credits}", + font=credits_font, + fill=(0, 0, 0, 255), + stroke_width=0, + stroke_fill=(255, 255, 255, 255), + ) + + artwork = Image.open("." + ball.collection_card).convert("RGBA") + image.paste(ImageOps.fit(artwork, artwork_size), CORNERS[0]) # type: ignore + + if icon: + icon = ImageOps.fit(icon, (192, 192)) + image.paste(icon, (1200, 30), mask=icon) + icon.close() + artwork.close() + + return image From 5fa552ae6a90f2f1f645766bbef3a24141396971 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Wed, 21 Aug 2024 20:13:57 +0300 Subject: [PATCH 06/25] adding poetry lock --no-update to pre commit (#339) Co-authored-by: Kowlin <10947836+Kowlin@users.noreply.github.com> --- .github/workflows/pre-commit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 95951715..c6379911 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -29,6 +29,8 @@ jobs: echo "$HOME/.poetry/bin" >> $GITHUB_PATH - name: Install dependencies run: poetry install --with=dev --no-interaction + - name: Lock dependencies + run: poetry lock --no-update - name: Run black run: poetry run black --check --diff $(git ls-files "*.py") - name: Run pre-commit checks From 43ebaed17ade7298efb9eac1a36d29a060115dcd Mon Sep 17 00:00:00 2001 From: Kowlin <10947836+Kowlin@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:33:11 +0200 Subject: [PATCH 07/25] fix: update pre-commit workflow to fix placing of locking (#347) --- .github/workflows/pre-commit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index c6379911..7f802f8a 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -27,10 +27,10 @@ jobs: run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.poetry/bin" >> $GITHUB_PATH - - name: Install dependencies - run: poetry install --with=dev --no-interaction - name: Lock dependencies run: poetry lock --no-update + - name: Install dependencies + run: poetry install --with=dev --no-interaction - name: Run black run: poetry run black --check --diff $(git ls-files "*.py") - name: Run pre-commit checks From 9edf78c562c4421a408c180a369f801faebc1f33 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:53:29 +0300 Subject: [PATCH 08/25] grammatical changes to responses in /admin balls reset and /admin balls count (#349) --- ballsdex/packages/admin/cog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ballsdex/packages/admin/cog.py b/ballsdex/packages/admin/cog.py index 3469b4b9..43c0d7dd 100644 --- a/ballsdex/packages/admin/cog.py +++ b/ballsdex/packages/admin/cog.py @@ -1085,7 +1085,7 @@ async def balls_reset( else: count = await BallInstance.filter(player=player).delete() await interaction.followup.send( - f"{count} {settings.collectible_name}s from {user} have been reset.", ephemeral=True + f"{count} {settings.collectible_name}s from {user} have been deleted.", ephemeral=True ) await log_action( f"{interaction.user} deleted {percentage or 100}% of " @@ -1127,6 +1127,7 @@ async def balls_count( filters["player__discord_id"] = user.id await interaction.response.defer(ephemeral=True, thinking=True) balls = await BallInstance.filter(**filters).count() + verb = "is" if balls == 1 else "are" country = f"{ball.country} " if ball else "" plural = "s" if balls > 1 or balls == 0 else "" special_str = f"{special.name} " if special else "" @@ -1138,7 +1139,7 @@ async def balls_count( ) else: await interaction.followup.send( - f"There are {balls} {special_str}{shiny_str}" + f"There {verb} {balls} {special_str}{shiny_str}" f"{country}{settings.collectible_name}{plural}." ) From fc1f756a05c35b5ec34f93f5700e4538c48d6829 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:55:08 +0300 Subject: [PATCH 09/25] [docker] version is deprecated and spits a warning (#335) Co-authored-by: Jamie <31554168+flaree@users.noreply.github.com> --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 534cce28..dcb7f33c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.4' - x-common-env-vars: - &postgres-db POSTGRES_DB=ballsdex From d18ea3f0087d8eb3a6c20a3b68554eaf1e480713 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:22:14 +0300 Subject: [PATCH 10/25] [config] adding max favorites to the generated config file (#355) --- ballsdex/settings.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ballsdex/settings.py b/ballsdex/settings.py index 2c10c568..d4666249 100644 --- a/ballsdex/settings.py +++ b/ballsdex/settings.py @@ -32,6 +32,8 @@ class Settings: Usually "BallsDex", can be replaced when possible players_group_cog_name: str Set the name of the base command of the "players" cog, /balls by default + max_favorites: + Set the maximum amount of favorited countryballs a user can have, 50 by default. about_description: str Used in the /about command github_link: str @@ -160,6 +162,9 @@ def write_default_settings(path: "Path"): # this is /balls by default, but you can change it for /animals or /rocks for example players-group-cog-name: balls +# maximum amount of favorites that are allowed +max-favorites: 50 + # enables the /admin command admin-command: @@ -202,6 +207,7 @@ def update_settings(path: "Path"): add_owners = True add_config_ref = "# yaml-language-server: $schema=json-config-ref.json" not in content + add_max_favorites = "max-favorites:" not in content for line in content.splitlines(): if line.startswith("owners:"): @@ -224,5 +230,11 @@ def update_settings(path: "Path"): else: content = "# yaml-language-server: $schema=json-config-ref.json\n" + content + if add_max_favorites: + content += """ +# maximum amount of favorites that are allowed +max-favorites: 50 +""" + if any((add_owners, add_config_ref)): path.write_text(content) From ad9bf97fd2504aae6f3b9293c2d62d372ec6914d Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Thu, 29 Aug 2024 21:25:15 +0300 Subject: [PATCH 11/25] [meta] Update CONTRIBUTING.md. (#358) --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0da767e..e0990fc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,10 +28,10 @@ Export the appropriate environment variables as described in the ### Installing the dependencies -1. Get Python 3.10 and pip -2. Install poetry with `pip install poetry` -3. Run `poetry install` -4. You may run commands inside the virtualenv with `poetry run ...`, or use `poetry shell` +1. Get Python 3.10 and pip. +2. Install poetry with `pip install poetry`. +3. Run `poetry install`. +4. You may run commands inside the virtualenv with `poetry run ...`, or use `poetry shell`. 5. Set up your IDE Python version to the one from Poetry. The path to the virtualenv can be obtained with `poetry show -v`. From 770efe3beba46428b2898d536a04f35ddbdd8a8d Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:32:11 +0300 Subject: [PATCH 12/25] grammatical changes to admin info guild/user (#359) * grammatical changes to admin info guild/user * leaving a whitespace for code formatting checks to pass --- ballsdex/packages/admin/cog.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/ballsdex/packages/admin/cog.py b/ballsdex/packages/admin/cog.py index 43c0d7dd..b910e921 100644 --- a/ballsdex/packages/admin/cog.py +++ b/ballsdex/packages/admin/cog.py @@ -1552,7 +1552,7 @@ async def guild( owner = await self.bot.fetch_user(guild.owner_id) embed = discord.Embed( title=f"{guild.name} ({guild.id})", - description=f"Owner: {owner} ({guild.owner_id})", + description=f"**Owner:** {owner} ({guild.owner_id})", color=discord.Color.blurple(), ) else: @@ -1560,15 +1560,15 @@ async def guild( title=f"{guild.name} ({guild.id})", color=discord.Color.blurple(), ) - embed.add_field(name="Members", value=guild.member_count) - embed.add_field(name="Spawn Enabled", value=spawn_enabled) - embed.add_field(name="Created at", value=format_dt(guild.created_at, style="R")) + embed.add_field(name="Members:", value=guild.member_count) + embed.add_field(name="Spawn enabled:", value=spawn_enabled) + embed.add_field(name="Created at:", value=format_dt(guild.created_at, style="F")) embed.add_field( - name=f"{settings.collectible_name} Caught ({days} days)", + name=f"{settings.collectible_name.title()}s caught ({days} days):", value=len(total_server_balls), ) embed.add_field( - name=f"Amount of Users who caught {settings.collectible_name} ({days} days)", + name="Amount of users who caught\n" f"{settings.collectible_name}s ({days} days):", value=len(set([x.player.discord_id for x in total_server_balls])), ) @@ -1605,30 +1605,33 @@ async def user( embed = discord.Embed( title=f"{user} ({user.id})", description=( - f"Privacy Policy: {PRIVATE_POLICY_MAP[player.privacy_policy]}\n" - f"Donation Policy: {DONATION_POLICY_MAP[player.donation_policy]}" + f"**Privacy Policy:** {PRIVATE_POLICY_MAP[player.privacy_policy]}\n" + f"**Donation Policy:** {DONATION_POLICY_MAP[player.donation_policy]}" ), color=discord.Color.blurple(), ) - embed.add_field(name=f"Balls Caught ({days} days)", value=len(total_user_balls)) embed.add_field( - name=f"{settings.collectible_name} Caught (Unique - ({days} days))", + name=f"{settings.collectible_name.title()}s caught ({days} days):", + value=len(total_user_balls), + ) + embed.add_field( + name=f"Unique {settings.collectible_name}s caught ({days} days):", value=len(set(total_user_balls)), ) embed.add_field( - name=f"Total Server with {settings.collectible_name}s caught ({days} days))", + name=f"Total servers with {settings.collectible_name}s caught ({days} days):", value=len(set([x.server_id for x in total_user_balls])), ) embed.add_field( - name=f"Total {settings.collectible_name}s Caught", + name=f"Total {settings.collectible_name}s caught:", value=await BallInstance.filter(player__discord_id=user.id).count(), ) embed.add_field( - name=f"Total Unique {settings.collectible_name}s Caught", + name=f"Total unique {settings.collectible_name}s caught:", value=len(set([x.countryball for x in total_user_balls])), ) embed.add_field( - name=f"Total Server with {settings.collectible_name}s Caught", + name=f"Total servers with {settings.collectible_name}s caught:", value=len(set([x.server_id for x in total_user_balls])), ) embed.set_thumbnail(url=user.display_avatar) # type: ignore From ed2fb214ee4f0783f836ea8a1946f3e803bb80da Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:41:53 +0300 Subject: [PATCH 13/25] [trade] fix countryball history arguement showing all history (#354) * fixing trade history sort countryball * changing the code for checks to pass --- ballsdex/packages/trade/cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ballsdex/packages/trade/cog.py b/ballsdex/packages/trade/cog.py index b1d8bcaf..9de98fdb 100644 --- a/ballsdex/packages/trade/cog.py +++ b/ballsdex/packages/trade/cog.py @@ -399,16 +399,16 @@ async def history( queryset = queryset.filter(date__range=(start_date, end_date)) if countryball: - queryset = queryset.filter( - Q(player1__tradeobjects__ballinstance__ball=countryball) - | Q(player2__tradeobjects__ballinstance__ball=countryball) - ).distinct() # for some reason, this query creates a lot of duplicate rows? + queryset = queryset.filter(Q(tradeobjects__ballinstance__ball=countryball)).distinct() - history = await queryset.order_by(sorting.value).prefetch_related("player1", "player2") + history = await queryset.order_by(sorting.value).prefetch_related( + "player1", "player2", "tradeobjects__ballinstance__ball" + ) if not history: await interaction.followup.send("No history found.", ephemeral=True) return + source = TradeViewFormat(history, interaction.user.name, self.bot) pages = Pages(source=source, interaction=interaction) await pages.start() From 8e9644ecc4ddc0afb08648ee0303ffd43230fe32 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:54:34 +0300 Subject: [PATCH 14/25] [balls] see total stats in list (#353) * seeing stats through balls list * splitting code for standardization Co-authored-by: Jamie <31554168+flaree@users.noreply.github.com> --------- Co-authored-by: Jamie <31554168+flaree@users.noreply.github.com> --- ballsdex/packages/balls/countryballs_paginator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ballsdex/packages/balls/countryballs_paginator.py b/ballsdex/packages/balls/countryballs_paginator.py index c840fa39..352dc663 100644 --- a/ballsdex/packages/balls/countryballs_paginator.py +++ b/ballsdex/packages/balls/countryballs_paginator.py @@ -38,8 +38,11 @@ def set_options(self, balls: List[BallInstance]): options.append( discord.SelectOption( label=f"{favorite}{shiny}{special}#{ball.pk:0X} {ball.countryball.country}", - description=f"ATK: {ball.attack_bonus:+d}% • HP: {ball.health_bonus:+d}% • " - f"Caught on {ball.catch_date.strftime('%d/%m/%y %H:%M')}", + description=( + f"ATK: {ball.attack}({ball.attack_bonus:+d}%) " + f"• HP: {ball.health}({ball.health_bonus:+d}%) • " + f"Caught on {ball.catch_date.strftime('%d/%m/%y %H:%M')}" + ), emoji=emoji, value=f"{ball.pk}", ) From 4d445f99ab2247b095d66bb65815ac45c6a82f15 Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:24:23 +0100 Subject: [PATCH 15/25] Don't show favourite icon in trade (#361) --- ballsdex/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ballsdex/core/models.py b/ballsdex/core/models.py index 6b4af6bc..97d8e570 100755 --- a/ballsdex/core/models.py +++ b/ballsdex/core/models.py @@ -240,7 +240,7 @@ def to_string(self, bot: discord.Client | None = None, is_trade: bool = False) - emotes = "" if bot and self.pk in bot.locked_balls and not is_trade: # type: ignore emotes += "🔒" - if self.favorite: + if self.favorite and not is_trade: emotes += "❤️" if self.shiny: emotes += "✨" From c2f33add13002660a54cecee31c36fc917896db9 Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:35:58 +0100 Subject: [PATCH 16/25] [balls] Show shiny and special in completion failure msg (#362) --- ballsdex/packages/balls/cog.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ballsdex/packages/balls/cog.py b/ballsdex/packages/balls/cog.py index dad64a61..9b5f5fe0 100644 --- a/ballsdex/packages/balls/cog.py +++ b/ballsdex/packages/balls/cog.py @@ -249,12 +249,14 @@ async def completion( Whether you want to see the completion of shiny countryballs """ user_obj = user or interaction.user + extra_text = "shiny " if shiny else "" + f"{special.name} " if special else "" if user is not None: try: player = await Player.get(discord_id=user_obj.id) except DoesNotExist: await interaction.response.send_message( - f"{user_obj.name} doesn't have any {settings.collectible_name}s yet." + f"{user_obj.name} doesn't have any " + f"{extra_text}{settings.collectible_name}s yet." ) return if await inventory_privacy(self.bot, interaction, player, user_obj) is False: @@ -274,10 +276,12 @@ async def completion( } if not bot_countryballs: await interaction.response.send_message( - f"There are no {settings.collectible_name}s registered on this bot yet.", + f"There are no {extra_text}{settings.collectible_name}s" + " registered on this bot yet.", ephemeral=True, ) return + await interaction.response.defer(thinking=True) if shiny is not None: From a6bfc0edb005508f4c79ba4d01cd5b9fae3d0963 Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:00:41 +0100 Subject: [PATCH 17/25] [info] More details credits regarding owner and project info (#363) --- ballsdex/packages/info/cog.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ballsdex/packages/info/cog.py b/ballsdex/packages/info/cog.py index 54660be4..bb2c2e1f 100644 --- a/ballsdex/packages/info/cog.py +++ b/ballsdex/packages/info/cog.py @@ -98,6 +98,18 @@ async def about(self, interaction: discord.Interaction): permissions=self.bot.application.install_params.permissions, scopes=self.bot.application.install_params.scopes, ) + + bot_info = await self.bot.application_info() + if bot_info.team: + owner = bot_info.team.name + else: + owner = bot_info.owner + owner_credits = "by the team" if bot_info.team else "by" + dex_credits = ( + f"This instance is owned {owner_credits} {owner}.\nAn instance of [Ballsdex]" + f"({settings.github_link}) by El Laggron and maintained by the Ballsdex Team " + f"and community of [contributors]({settings.github_link}/graphs/contributors)." + ) embed.description = ( f"{' '.join(str(x) for x in balls)}\n" f"{settings.about_description}\n" @@ -106,7 +118,8 @@ async def about(self, interaction: discord.Interaction): f"**{players_count:,}** players that caught " f"**{balls_instances_count:,}** {settings.collectible_name}s\n" f"**{len(self.bot.guilds):,}** servers playing\n\n" - "This bot was made by **El Laggron**, consider supporting me on my " + f"{dex_credits}\n\n" + "Consider supporting El Laggron on " "[Patreon](https://patreon.com/retke) :heart:\n\n" f"[Discord server]({settings.discord_invite}) • [Invite me]({invite_link}) • " f"[Source code and issues]({settings.github_link})\n" From 70dfbe40f0f00d811b3b0893befc5967c4b58825 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:17:30 +0300 Subject: [PATCH 18/25] adding dimensions to the collection card label in admin/resources (#357) * adding dimensions to the collection card label * changing dimensions to ratio --- ballsdex/core/admin/resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ballsdex/core/admin/resources.py b/ballsdex/core/admin/resources.py index ad57c6e9..b9e52a2f 100755 --- a/ballsdex/core/admin/resources.py +++ b/ballsdex/core/admin/resources.py @@ -102,7 +102,7 @@ class SpecialResource(Model): "rarity", Field( name="background", - label="Special background", + label="Special background (1428x2000)", display=displays.Image(width="40"), input_=inputs.Image(upload=upload, null=True), ), @@ -205,7 +205,7 @@ class BallResource(Model): ), Field( name="collection_card", - label="Collection card", + label="Collection card (16:9 ratio)", display=displays.Image(width="40"), input_=inputs.Image(upload=upload, null=True), ), From 64325edfc34fb147c6ad70d669292a8fed1b9742 Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:26:01 +0300 Subject: [PATCH 19/25] handling permission checks correctly with details (#360) * handling permission checks correctly with details * changing ' to " for formatting checks to pass * removing the unnecessary return * adding the disabledcommand check back * making the 2 error types a tuple --- ballsdex/core/bot.py | 67 +++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/ballsdex/core/bot.py b/ballsdex/core/bot.py index a7f977e7..9f9e2355 100755 --- a/ballsdex/core/bot.py +++ b/ballsdex/core/bot.py @@ -364,9 +364,7 @@ async def blacklist_check(self, interaction: discord.Interaction) -> bool: async def on_command_error( self, context: commands.Context, exception: commands.errors.CommandError ): - if isinstance( - exception, (commands.CommandNotFound, commands.CheckFailure, commands.DisabledCommand) - ): + if isinstance(exception, (commands.CommandNotFound, commands.DisabledCommand)): return assert context.command @@ -380,26 +378,34 @@ async def on_command_error( await context.send("An attachment is missing.") return - if isinstance(exception, commands.CommandInvokeError): - if isinstance(exception.original, discord.Forbidden): - await context.send("The bot does not have the permission to do something.") - # log to know where permissions are lacking + if isinstance(exception, commands.CheckFailure): + if isinstance(exception, commands.BotMissingPermissions): + missing_perms = ", ".join(exception.missing_permissions) + await context.send( + f"The bot is missing the permissions: `{missing_perms}`." + " Give the bot those permissions for the command to work as expected." + ) log.warning( - f"Missing permissions for text command {context.command.name}", - exc_info=exception.original, + f"Missing bot permissions for command {context.command.name}: {missing_perms}", + exc_info=exception, + ) + return + + if isinstance(exception, commands.MissingPermissions): + missing_perms = ", ".join(exception.missing_permissions) + await context.send( + f"You are missing the following permissions: `{missing_perms}`." + " You need those permissions to run this command." ) return - log.error(f"Error in text command {context.command.name}", exc_info=exception.original) + return + + if isinstance(exception, commands.CommandInvokeError): await context.send( "An error occured when running the command. Contact support if this persists." ) - return - - await context.send( - "An error occured when running the command. Contact support if this persists." - ) - log.error(f"Unknown error in text command {context.command.name}", exc_info=exception) + log.error(f"Unknown error in text command {context.command.name}", exc_info=exception) async def on_application_command_error( self, interaction: discord.Interaction, error: app_commands.AppCommandError @@ -410,14 +416,35 @@ async def send(content: str): else: await interaction.response.send_message(content, ephemeral=True) + if isinstance(error, app_commands.CommandOnCooldown): + await send( + "This command is on cooldown. Please retry " + f"." + ) + return + if isinstance(error, app_commands.CheckFailure): - if isinstance(error, app_commands.CommandOnCooldown): + if isinstance(error, app_commands.BotMissingPermissions): + missing_perms = ", ".join(error.missing_permissions) await send( - "This command is on cooldown. Please retry " - f"." + f"The bot is missing the permissions: `{missing_perms}`." + " Give the bot those permissions for the command to work as expected." + ) + command_name = getattr(interaction.command, "name", "unknown") + log.warning( + f"Missing bot permissions for command {command_name}: {missing_perms}", + exc_info=error, ) return - await send("You are not allowed to use that command.") + + if isinstance(error, app_commands.MissingPermissions): + missing_perms = ", ".join(error.missing_permissions) + await send( + f"You are missing the following permissions: `{missing_perms}`." + " You need those permissions to run this command." + ) + return + return if isinstance(error, app_commands.TransformerError): From 173a1aed6ada8bf9b210b6c857788fe135cd1edc Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:32:33 +0100 Subject: [PATCH 20/25] [trade] Add trade view (#364) * Add trade view * fix recursion error --- ballsdex/packages/trade/cog.py | 20 +++++++- ballsdex/packages/trade/display.py | 12 +++-- ballsdex/packages/trade/menu.py | 74 +++++++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/ballsdex/packages/trade/cog.py b/ballsdex/packages/trade/cog.py index 9de98fdb..2bf41f28 100644 --- a/ballsdex/packages/trade/cog.py +++ b/ballsdex/packages/trade/cog.py @@ -19,7 +19,7 @@ TradeCommandType, ) from ballsdex.packages.trade.display import TradeViewFormat -from ballsdex.packages.trade.menu import BulkAddView, TradeMenu +from ballsdex.packages.trade.menu import BulkAddView, TradeMenu, TradeViewMenu from ballsdex.packages.trade.trade_user import TradingUser from ballsdex.settings import settings @@ -412,3 +412,21 @@ async def history( source = TradeViewFormat(history, interaction.user.name, self.bot) pages = Pages(source=source, interaction=interaction) await pages.start() + + @app_commands.command() + async def view( + self, + interaction: discord.Interaction["BallsDexBot"], + ): + """ + View the countryballs added to an ongoing trade. + """ + trade, trader = self.get_trade(interaction) + if not trade or not trader: + await interaction.response.send_message( + "You do not have an ongoing trade.", ephemeral=True + ) + return + + source = TradeViewMenu(interaction, [trade.trader1, trade.trader2], self) + await source.start(content="Select a user to view their proposal.") diff --git a/ballsdex/packages/trade/display.py b/ballsdex/packages/trade/display.py index 7b5b5117..014b3cea 100644 --- a/ballsdex/packages/trade/display.py +++ b/ballsdex/packages/trade/display.py @@ -167,14 +167,20 @@ def fill_trade_embed_fields( f"{_get_prefix_emote(trader1)} {trader1.user.name}" f" {trader1.user.id if is_admin else ''}" ), - value=f"Trade too long, only showing last page:\n{trader1_proposal[-1]}", + value=( + f"Trade too long, only showing last page:\n{trader1_proposal[-1]}" + f"\nTotal: {len(trader1.proposal)}" + ), inline=True, ) embed.add_field( name=( f"{_get_prefix_emote(trader2)} {trader2.user.name}" - f" {trader2.user.id if is_admin else ''}", + f" {trader2.user.id if is_admin else ''}" + ), + value=( + f"Trade too long, only showing last page:\n{trader2_proposal[-1]}\n" + f"Total: {len(trader2.proposal)}" ), - value=f"Trade too long, only showing last page:\n{trader2_proposal[-1]}", inline=True, ) diff --git a/ballsdex/packages/trade/menu.py b/ballsdex/packages/trade/menu.py index 31fd8d20..64fd6e60 100644 --- a/ballsdex/packages/trade/menu.py +++ b/ballsdex/packages/trade/menu.py @@ -3,14 +3,15 @@ import asyncio import logging from datetime import datetime, timedelta -from typing import TYPE_CHECKING, List, cast +from typing import TYPE_CHECKING, List, Set, cast import discord from discord.ui import Button, View, button -from ballsdex.core.models import BallInstance, Trade, TradeObject +from ballsdex.core.models import BallInstance, Player, Trade, TradeObject from ballsdex.core.utils import menus from ballsdex.core.utils.paginator import Pages +from ballsdex.packages.balls.countryballs_paginator import CountryballsViewer from ballsdex.packages.trade.display import fill_trade_embed_fields from ballsdex.packages.trade.trade_user import TradingUser from ballsdex.settings import settings @@ -165,6 +166,7 @@ def _get_trader(self, user: discord.User | discord.Member) -> TradingUser: def _generate_embed(self): add_command = self.cog.add.extras.get("mention", "`/trade add`") remove_command = self.cog.remove.extras.get("mention", "`/trade remove`") + view_command = self.cog.view.extras.get("mention", "`/trade view`") self.embed.title = f"{settings.collectible_name.title()}s trading" self.embed.color = discord.Colour.blurple() @@ -173,7 +175,8 @@ def _generate_embed(self): f"using the {add_command} and {remove_command} commands.\n" "Once you're finished, click the lock button below to confirm your proposal.\n" "You can also lock with nothing if you're receiving a gift.\n\n" - "*You have 30 minutes before this interaction ends.*" + "*You have 30 minutes before this interaction ends.*\n\n" + f"Use the {view_command} command to see the full list of {settings.collectible_name}s " ) self.embed.set_footer( text="This message is updated every 15 seconds, " @@ -363,13 +366,13 @@ def __init__( self.add_item(self.select_ball_menu) self.add_item(self.confirm_button) self.add_item(self.clear_button) - self.balls_selected: List[BallInstance] = [] + self.balls_selected: Set[BallInstance] = set() self.cog = cog def set_options(self, balls: List[BallInstance]): options: List[discord.SelectOption] = [] for ball in balls: - if ball.is_tradeable is False or ball.ball.enabled is False: + if ball.is_tradeable is False: continue emoji = self.bot.get_emoji(int(ball.countryball.emoji_id)) favorite = "❤️ " if ball.favorite else "" @@ -393,7 +396,7 @@ async def select_ball_menu(self, interaction: discord.Interaction, item: discord ball_instance = await BallInstance.get(id=int(value)).prefetch_related( "ball", "player" ) - self.balls_selected.append(ball_instance) + self.balls_selected.add(ball_instance) await interaction.response.defer() @discord.ui.button(label="Confirm", style=discord.ButtonStyle.primary) @@ -452,3 +455,62 @@ async def clear_button(self, interaction: discord.Interaction, button: Button): class BulkAddView(CountryballsSelector): async def on_timeout(self) -> None: return await super().on_timeout() + + +class TradeViewSource(menus.ListPageSource): + def __init__(self, entries: List[TradingUser]): + super().__init__(entries, per_page=25) + + async def format_page(self, menu, players: List[TradingUser]): + menu.set_options(players) + return True # signal to edit the page + + +class TradeViewMenu(Pages): + def __init__( + self, + interaction: discord.Interaction["BallsDexBot"], + proposal: List[TradingUser], + cog: TradeCog, + ): + self.bot = interaction.client + source = TradeViewSource(proposal) + super().__init__(source, interaction=interaction) + self.add_item(self.select_player_menu) + self.cog = cog + + def set_options(self, players: List[TradingUser]): + options: List[discord.SelectOption] = [] + for player in players: + user_obj = player.user + options.append( + discord.SelectOption( + label=f"{user_obj.display_name}", + description=( + f"ID: {user_obj.id} | {len(player.proposal)} " + f"{settings.collectible_name}s" + ), + value=f"{user_obj.id}", + ) + ) + self.select_player_menu.options = options + + @discord.ui.select() + async def select_player_menu( + self, interaction: discord.Interaction["BallsDexBot"], item: discord.ui.Select + ): + player = await Player.get(discord_id=int(item.values[0])) + trade, trader = self.cog.get_trade(interaction) + if trade is None or trader is None: + return await interaction.followup.send( + "The trade has been cancelled or the user is not part of the trade.", + ephemeral=True, + ) + trade_player = ( + trade.trader1 if trade.trader1.user.id == player.discord_id else trade.trader2 + ) + ball_instances = trade_player.proposal + + await interaction.response.defer(thinking=True) + paginator = CountryballsViewer(interaction, ball_instances) + await paginator.start() From 603efd6568f113da3fd6bc3d74035bd51fc6fcdc Mon Sep 17 00:00:00 2001 From: imtherealF1 <168587794+imtherealF1@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:42:03 +0300 Subject: [PATCH 21/25] [config] adding interaction check to config embed, making channel optional, and some logic changes (#350) * adding interaction check to config embed and small logic changes * making channel optional, if no channel the bot uses interaction channel * 3 lines into 1 for black checks to pass * changing the code for pyright * changes for pyright checks to pass * removing extra newline at the end of file * checking if bot has the correct permissions with a decorator * passing discord.Member instead of Player * more changes for pyright * even more changes for pyright * ensuring channel is a Text.Channel and not interaction.channel * changing the code to access the disabled attribute * making sure the channel is a text channel * changing cmd description and making code easier and simpler * small change in the bot's response * re-adding a description that i accidentally removed * leaving whitespace between description and init * adding user permission checks in a decorator * removing an unnecessary "self.stop()" * adding permission checks in decorators in /config disable * removing unnecessary permission checks --------- Co-authored-by: Jamie <31554168+flaree@users.noreply.github.com> --- ballsdex/packages/config/cog.py | 76 ++++++++++++++------------ ballsdex/packages/config/components.py | 53 +++++++++++------- 2 files changed, 75 insertions(+), 54 deletions(-) diff --git a/ballsdex/packages/config/cog.py b/ballsdex/packages/config/cog.py index db739509..008472b9 100644 --- a/ballsdex/packages/config/cog.py +++ b/ballsdex/packages/config/cog.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Optional, cast import discord from discord import app_commands @@ -36,53 +36,52 @@ def __init__(self, bot: "BallsDexBot"): self.bot = bot @app_commands.command() - @app_commands.describe(channel="The new text channel to set.") + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.bot_has_permissions( + read_messages=True, + send_messages=True, + embed_links=True, + ) async def channel( self, interaction: discord.Interaction, - channel: discord.TextChannel, + channel: Optional[discord.TextChannel] = None, ): """ Set or change the channel where countryballs will spawn. + + Parameters + ---------- + channel: discord.TextChannel + The channel you want to set, current one if not specified. """ - guild = cast(discord.Guild, interaction.guild) # guild-only command user = cast(discord.Member, interaction.user) - if not user.guild_permissions.manage_guild: - await interaction.response.send_message( - "You need the permission to manage the server to use this." - ) - return - if not channel.permissions_for(guild.me).read_messages: - await interaction.response.send_message( - f"I need the permission to read messages in {channel.mention}." - ) - return - if not channel.permissions_for(guild.me).send_messages: - await interaction.response.send_message( - f"I need the permission to send messages in {channel.mention}." - ) - return - if not channel.permissions_for(guild.me).embed_links: - await interaction.response.send_message( - f"I need the permission to send embed links in {channel.mention}." - ) - return + + if channel is None: + if isinstance(interaction.channel, discord.TextChannel): + channel = interaction.channel + else: + await interaction.response.send_message( + "The current channel is not a valid text channel.", ephemeral=True + ) + return + + view = AcceptTOSView(interaction, channel, user) + message = await channel.send(embed=activation_embed, view=view) + view.message = message + await interaction.response.send_message( - embed=activation_embed, view=AcceptTOSView(interaction, channel) + f"The activation embed has been sent in {channel.mention}.", ephemeral=True ) @app_commands.command() + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.bot_has_permissions(send_messages=True) async def disable(self, interaction: discord.Interaction): """ Disable or enable countryballs spawning. """ guild = cast(discord.Guild, interaction.guild) # guild-only command - user = cast(discord.Member, interaction.user) - if not user.guild_permissions.manage_guild: - await interaction.response.send_message( - "You need the permission to manage the server to use this." - ) - return config, created = await GuildConfig.get_or_create(guild_id=interaction.guild_id) if config.enabled: config.enabled = False # type: ignore @@ -98,10 +97,17 @@ async def disable(self, interaction: discord.Interaction): await config.save() self.bot.dispatch("ballsdex_settings_change", guild, enabled=True) if config.spawn_channel and (channel := guild.get_channel(config.spawn_channel)): - await interaction.response.send_message( - f"{settings.bot_name} is now enabled in this server, " - f"{settings.collectible_name}s will start spawning soon in {channel.mention}." - ) + if channel: + await interaction.response.send_message( + f"{settings.bot_name} is now enabled in this server, " + f"{settings.collectible_name}s will start spawning " + f"soon in {channel.mention}." + ) + else: + await interaction.response.send_message( + "The spawning channel specified in the configuration is not available.", + ephemeral=True, + ) else: await interaction.response.send_message( f"{settings.bot_name} is now enabled in this server, however there is no " diff --git a/ballsdex/packages/config/components.py b/ballsdex/packages/config/components.py index 66fc12f0..8767ffae 100644 --- a/ballsdex/packages/config/components.py +++ b/ballsdex/packages/config/components.py @@ -1,3 +1,5 @@ +from typing import Optional + import discord from discord.ui import Button, View, button @@ -10,10 +12,18 @@ class AcceptTOSView(View): Button prompting the admin setting up the bot to accept the terms of service. """ - def __init__(self, interaction: discord.Interaction, channel: discord.TextChannel): + def __init__( + self, + interaction: discord.Interaction, + channel: discord.TextChannel, + new_player: discord.Member, + ): super().__init__() self.original_interaction = interaction self.channel = channel + self.new_player = new_player + self.message: Optional[discord.Message] = None + self.add_item( Button( style=discord.ButtonStyle.link, @@ -29,12 +39,20 @@ def __init__(self, interaction: discord.Interaction, channel: discord.TextChanne ) ) + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.new_player.id: + await interaction.response.send_message( + "You are not allowed to interact with this menu.", ephemeral=True + ) + return False + return True + @button( label="Accept", style=discord.ButtonStyle.success, emoji="\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}", ) - async def accept_button(self, interaction: discord.Interaction, item: discord.ui.Button): + async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): config, created = await GuildConfig.get_or_create(guild_id=interaction.guild_id) config.spawn_channel = self.channel.id # type: ignore await config.save() @@ -42,27 +60,24 @@ async def accept_button(self, interaction: discord.Interaction, item: discord.ui "ballsdex_settings_change", interaction.guild, channel=self.channel ) self.stop() + if self.message: + button.disabled = True + try: + await self.message.edit(view=self) + except discord.HTTPException: + pass await interaction.response.send_message( f"The new spawn channel was successfully set to {self.channel.mention}.\n" f"{settings.collectible_name.title()}s will start spawning as" " users talk unless the bot is disabled." ) - self.accept_button.disabled = True - try: - await self.original_interaction.followup.edit_message( - "@original", view=self # type: ignore - ) - except discord.HTTPException: - pass - async def on_timeout(self) -> None: - self.stop() - for item in self.children: - item.disabled = True # type: ignore - try: - await self.original_interaction.followup.edit_message( - "@original", view=self # type: ignore - ) - except discord.HTTPException: - pass + if self.message: + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + try: + await self.message.edit(view=self) + except discord.HTTPException: + pass From a3a9008c1752e32f097a7d32f8324901313496d4 Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:43:21 +0100 Subject: [PATCH 22/25] [trade] Add ability to select all on page (#365) * Add ability to select all on page * Update menu.py --- ballsdex/packages/trade/menu.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ballsdex/packages/trade/menu.py b/ballsdex/packages/trade/menu.py index 64fd6e60..2743fc82 100644 --- a/ballsdex/packages/trade/menu.py +++ b/ballsdex/packages/trade/menu.py @@ -365,6 +365,7 @@ def __init__( super().__init__(source, interaction=interaction) self.add_item(self.select_ball_menu) self.add_item(self.confirm_button) + self.add_item(self.select_all_button) self.add_item(self.clear_button) self.balls_selected: Set[BallInstance] = set() self.cog = cog @@ -399,6 +400,23 @@ async def select_ball_menu(self, interaction: discord.Interaction, item: discord self.balls_selected.add(ball_instance) await interaction.response.defer() + @discord.ui.button(label="Select Page", style=discord.ButtonStyle.secondary) + async def select_all_button(self, interaction: discord.Interaction, button: Button): + await interaction.response.defer(thinking=True, ephemeral=True) + for ball in self.select_ball_menu.options: + ball_instance = await BallInstance.get(id=int(ball.value)).prefetch_related( + "ball", "player" + ) + if ball_instance not in self.balls_selected: + self.balls_selected.add(ball_instance) + await interaction.followup.send( + ( + f"All {settings.collectible_name}s on this page have been selected.\n" + "Note that the menu may not reflect this change until you change page." + ), + ephemeral=True, + ) + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.primary) async def confirm_button(self, interaction: discord.Interaction, button: Button): await interaction.response.defer(thinking=True, ephemeral=True) From 541a1b1630526db2cf1d1f7d50221b773ca1f397 Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:43:28 +0100 Subject: [PATCH 23/25] Update issue templates (#287) --- .github/ISSUE_TEMPLATE/bug_report.md | 30 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 14 +++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..1ae61414 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a bug report +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Version:** + - Can be found in /about (applicable if self hosting) + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..c659523e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for Ballsdex +title: '' +labels: '' +assignees: '' + +--- + +**Describe the feature you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. From 203d7832ed9f182a0f7183b5c37cf1954297f32b Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:47:24 +0100 Subject: [PATCH 24/25] made rarity also sort by country name (#367) --- ballsdex/packages/balls/cog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ballsdex/packages/balls/cog.py b/ballsdex/packages/balls/cog.py index 9b5f5fe0..ec29ecaf 100644 --- a/ballsdex/packages/balls/cog.py +++ b/ballsdex/packages/balls/cog.py @@ -197,6 +197,10 @@ async def list( elif sort == SortingChoices.total_stats: countryballs = await player.balls.filter(**filters) countryballs.sort(key=lambda x: x.health + x.attack, reverse=True) + elif sort == SortingChoices.rarity: + countryballs = await player.balls.filter(**filters).order_by( + sort.value, "ball__country" + ) else: countryballs = await player.balls.filter(**filters).order_by(sort.value) else: From fae40f300414c8eadc06b38c662855cf595a868f Mon Sep 17 00:00:00 2001 From: Jamie <31554168+flaree@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:48:20 +0100 Subject: [PATCH 25/25] [ballsdex] version bump --- ballsdex/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ballsdex/__init__.py b/ballsdex/__init__.py index 68a0e512..326b86ff 100755 --- a/ballsdex/__init__.py +++ b/ballsdex/__init__.py @@ -1 +1 @@ -__version__ = "2.18.1" +__version__ = "2.19.0"