From 92e616a0e243bac9e7946ba2ab612a8bcd9d52fa Mon Sep 17 00:00:00 2001 From: lexicalunit Date: Mon, 18 Nov 2024 21:46:07 -0800 Subject: [PATCH] Delete games when there's no players pending --- CHANGELOG.md | 7 + poetry.lock | 22 ++-- pyproject.toml | 1 + src/spellbot/actions/__init__.py | 2 - src/spellbot/actions/leave_action.py | 57 ++++++-- src/spellbot/actions/lfg_action.py | 87 +------------ src/spellbot/actions/record_action.py | 180 -------------------------- src/spellbot/client.py | 3 +- src/spellbot/cogs/__init__.py | 6 - src/spellbot/cogs/mod_cog.py | 46 ------- src/spellbot/cogs/owner_cog.py | 17 ++- src/spellbot/cogs/record_cog.py | 72 ----------- src/spellbot/cogs/sync_cog.py | 37 ------ src/spellbot/views/__init__.py | 2 - src/spellbot/views/lfg_view.py | 56 -------- tests/cogs/test_leave_cog.py | 118 +++++++++++++---- tests/cogs/test_mod_cog.py | 151 --------------------- tests/cogs/test_owner_cog.py | 22 ++++ tests/cogs/test_sync_cog.py | 38 ------ 19 files changed, 200 insertions(+), 724 deletions(-) delete mode 100644 src/spellbot/actions/record_action.py delete mode 100644 src/spellbot/cogs/mod_cog.py delete mode 100644 src/spellbot/cogs/record_cog.py delete mode 100644 src/spellbot/cogs/sync_cog.py delete mode 100644 tests/cogs/test_mod_cog.py delete mode 100644 tests/cogs/test_sync_cog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d19c5333..68de90b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - Updated dependencies. +- Delete the pending game when the last player leaves it. +- Cleaned up some code by reducing the number of cogs. + +### Removed + +- Removes the unfinished ELO and points confirmation code. This can be reintroduced in the future + but it needs to be fully overhauled from its current state. ## [v13.0.1](https://github.com/lexicalunit/spellbot/releases/tag/v13.0.1) - 2024-12-15 diff --git a/poetry.lock b/poetry.lock index 40296508..9e281c3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -201,22 +201,22 @@ test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "attrs" -version = "24.3.0" +version = "23.2.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, - {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] [[package]] name = "babel" @@ -2844,4 +2844,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12,<4" -content-hash = "2cb43457028ff2d93186c14ba991e53c9ddb6e9fff657218ecc5a99a6b5e0d88" +content-hash = "75569556d5912220887d48e607b8c05a4ee7dbcf94c2f90df2a74282b4fcc6e1" diff --git a/pyproject.toml b/pyproject.toml index 048f7b00..e5772331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,6 +145,7 @@ aiohttp-jinja2 = "^1.6" aiohttp-retry = "^2.8.3" alembic = "^1.13.1" asgiref = "^3.8.1" +attrs = "<24,>=23.2.0" babel = "^2.14.0" certifi = ">=2024.2.2,<2025.0.0" click = "^8.1.7" diff --git a/src/spellbot/actions/__init__.py b/src/spellbot/actions/__init__.py index a5d61912..c04d62b8 100644 --- a/src/spellbot/actions/__init__.py +++ b/src/spellbot/actions/__init__.py @@ -4,7 +4,6 @@ from .block_action import BlockAction from .leave_action import LeaveAction from .lfg_action import LookingForGameAction -from .record_action import RecordAction from .score_action import ScoreAction from .tasks_action import TasksAction from .verify_action import VerifyAction @@ -15,7 +14,6 @@ "BlockAction", "LeaveAction", "LookingForGameAction", - "RecordAction", "ScoreAction", "TasksAction", "VerifyAction", diff --git a/src/spellbot/actions/leave_action.py b/src/spellbot/actions/leave_action.py index 24ba5b25..f5a4b015 100644 --- a/src/spellbot/actions/leave_action.py +++ b/src/spellbot/actions/leave_action.py @@ -5,6 +5,7 @@ from ddtrace import tracer from spellbot.operations import ( + safe_delete_message, safe_fetch_text_channel, safe_get_partial_message, safe_original_response, @@ -34,6 +35,10 @@ async def _handle_click(self) -> None: game_data = await self.services.games.to_dict() posts = game_data.get("posts") + + player_count = len(await self.services.games.player_xids()) + do_delete_game = player_count == 0 + for post in posts: guild_xid = post["guild_xid"] channel_xid = post["channel_xid"] @@ -41,8 +46,12 @@ async def _handle_click(self) -> None: original_response = await safe_original_response(self.interaction) if original_response and message_xid and original_response.id == message_xid: - embed = await self.services.games.to_embed() - await safe_update_embed_origin(self.interaction, embed=embed) + if do_delete_game: + assert self.interaction.message is not None + await safe_delete_message(self.interaction.message) + else: + embed = await self.services.games.to_embed() + await safe_update_embed_origin(self.interaction, embed=embed) continue channel = await safe_fetch_text_channel(self.bot, guild_xid, channel_xid) @@ -53,8 +62,14 @@ async def _handle_click(self) -> None: if message is None: continue - embed = await self.services.games.to_embed() - await safe_update_embed(message, embed=embed) + if do_delete_game: + await safe_delete_message(message) + else: + embed = await self.services.games.to_embed() + await safe_update_embed(message, embed=embed) + + if do_delete_game: + await self.services.games.delete_games([game_data["id"]]) @tracer.wrap() async def _handle_command(self) -> None: @@ -74,6 +89,8 @@ async def _handle_command(self) -> None: await self.services.users.leave_game(channel_xid) game_data = await self.services.games.to_dict() + player_count = len(await self.services.games.player_xids()) + do_delete_game = player_count == 0 for post in game_data.get("posts", []): chan_xid = post["channel_xid"] guild_xid = post["guild_xid"] @@ -84,8 +101,14 @@ async def _handle_command(self) -> None: if not (message := safe_get_partial_message(channel, guild_xid, message_xid)): continue - embed = await self.services.games.to_embed() - await safe_update_embed(message, embed=embed) + if do_delete_game: + await safe_delete_message(message) + else: + embed = await self.services.games.to_embed() + await safe_update_embed(message, embed=embed) + + if do_delete_game: + await self.services.games.delete_games([game_data["id"]]) await safe_send_channel( self.interaction, @@ -105,9 +128,12 @@ async def execute_all(self) -> None: """Leave ALL games in ALL channels for this user.""" game_ids = await self.services.games.dequeue_players([self.interaction.user.id]) message_xids = await self.services.games.message_xids(game_ids) + for message_xid in message_xids: data = await self.services.games.select_by_message_xid(message_xid) assert data is not None # This should never happen + player_count = len(await self.services.games.player_xids()) + do_delete_game = player_count == 0 for post in data.get("posts", []): guild_xid = post["guild_xid"] @@ -116,12 +142,19 @@ async def execute_all(self) -> None: if channel: message = safe_get_partial_message(channel, guild_xid, message_xid) if message: - embed = await self.services.games.to_embed() - await safe_update_embed( - message, - embed=embed, - view=PendingGameView(bot=self.bot), - ) + if do_delete_game: + await safe_delete_message(message) + else: + embed = await self.services.games.to_embed() + await safe_update_embed( + message, + embed=embed, + view=PendingGameView(bot=self.bot), + ) + + if do_delete_game: + await self.services.games.delete_games([data["id"]]) + await safe_send_channel( self.interaction, "You were removed from all pending games.", diff --git a/src/spellbot/actions/lfg_action.py b/src/spellbot/actions/lfg_action.py index 40cc2900..9bb4a432 100644 --- a/src/spellbot/actions/lfg_action.py +++ b/src/spellbot/actions/lfg_action.py @@ -24,7 +24,7 @@ safe_update_embed_origin, ) from spellbot.settings import settings -from spellbot.views import BaseView, PendingGameView, StartedGameView, StartedGameViewWithConfirm +from spellbot.views import BaseView, PendingGameView, StartedGameView from .base_action import BaseAction @@ -141,11 +141,8 @@ async def upsert_game( ): embed = await self.services.games.to_embed() view: BaseView | None = None - if self.channel_data.get("show_points", False) and not found["confirmed"]: - if self.channel_data.get("require_confirmation", False): - view = StartedGameViewWithConfirm(bot=self.bot) - else: - view = StartedGameView(bot=self.bot) + if self.channel_data.get("show_points", False): + view = StartedGameView(bot=self.bot) await safe_update_embed(message, embed=embed, view=view) return None @@ -154,7 +151,7 @@ async def upsert_game( return False @tracer.wrap() - async def execute( # noqa: C901 + async def execute( self, friends: str | None = None, seats: int | None = None, @@ -186,14 +183,6 @@ async def execute( # noqa: C901 await safe_followup_channel(self.interaction, msg) return None - req_confirm = self.channel_data["require_confirmation"] - if req_confirm and not await self.services.users.is_confirmed(self.channel.id): - msg = "You need to confirm your points before joining another game." - if origin: - return await safe_send_user(self.interaction.user, msg) - await safe_followup_channel(self.interaction, msg) - return None - if await self.services.users.pending_games() + 1 > settings.MAX_PENDING_GAMES: msg = "You're in too many pending games to join another one at this time." if origin: @@ -287,71 +276,10 @@ async def add_points(self, message: Message, points: int) -> None: ) return - plays = await self.services.games.get_plays() - if plays.get(self.interaction.user.id, {}).get("confirmed_at", None): - await safe_send_user( - self.interaction.user, - f"You've already confirmed your points for game SB{found.get('id')}.", - ) - return - - # if at least one player has confirmed their points, then changing points not allowed - if any(play.get("confirmed_at") is not None for play in plays.values()): - await safe_send_user( - self.interaction.user, - ( - f"Points for game SB{found.get('id')} are locked in," - " please confirm them or contact a mod." - ), - ) - return - await self.services.games.add_points(self.interaction.user.id, points) embed = await self.services.games.to_embed() await safe_update_embed(message, embed=embed) - @tracer.wrap() - async def confirm_points(self, message: Message) -> None: - found = await self.services.games.select_by_message_xid(message.id) - if not found: - return - - if not await self.services.games.players_included(self.interaction.user.id): - await safe_send_user( - self.interaction.user, - f"You are not one of the players in game SB{found.get('id')}.", - ) - return - - plays = await self.services.games.get_plays() - if plays.get(self.interaction.user.id, {}).get("confirmed_at", None): - await safe_send_user( - self.interaction.user, - f"You've already confirmed your points for game SB{found.get('id')}", - ) - return - - if any(play.get("points") is None for play in plays.values()): - await safe_send_user( - self.interaction.user, - ( - "Please wait until all players have reported" - f" before confirming points for game SB{found.get('id')}" - ), - ) - return - - confirmed_at = await self.services.games.confirm_points(player_xid=self.interaction.user.id) - embed = await self.services.games.to_embed() - data = await self.services.games.to_dict() - if data["confirmed"]: - await safe_update_embed(message, embed=embed, view=None) - else: - await safe_update_embed(message, embed=embed) - plays[self.interaction.user.id]["confirmed_at"] = confirmed_at - if all(play["confirmed_at"] is not None for play in plays.values()): - await self.services.games.update_records(plays) - @tracer.wrap() async def create_game( self, @@ -493,11 +421,8 @@ async def _handle_embed_creation( # noqa: C901,PLR0912 view: BaseView | None = None if fully_seated: - if self.channel_data.get("show_points", False) and not game_data["confirmed"]: - if self.channel_data.get("require_confirmation", False): - view = StartedGameViewWithConfirm(bot=self.bot) - else: - view = StartedGameView(bot=self.bot) + if self.channel_data.get("show_points", False): + view = StartedGameView(bot=self.bot) else: view = PendingGameView(bot=self.bot) diff --git a/src/spellbot/actions/record_action.py b/src/spellbot/actions/record_action.py deleted file mode 100644 index d2244abc..00000000 --- a/src/spellbot/actions/record_action.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -import discord -from ddtrace import tracer - -from spellbot.operations import ( - safe_fetch_text_channel, - safe_get_partial_message, - safe_send_channel, - safe_update_embed, -) -from spellbot.settings import settings - -from .base_action import BaseAction - -if TYPE_CHECKING: - from collections.abc import ValuesView - - from spellbot import SpellBot - from spellbot.models import GameDict, PlayDict - -logger = logging.getLogger(__name__) - - -class RecordAction(BaseAction): - def __init__(self, bot: SpellBot, interaction: discord.Interaction) -> None: - super().__init__(bot, interaction) - - async def _report_embed(self, game: GameDict, plays: ValuesView[PlayDict]) -> discord.Embed: - assert self.guild is not None - embed = discord.Embed() - embed.set_thumbnail(url=settings.ICO_URL) - embed.set_author(name=f"SB{game['id']} Game Report") - embed.color = settings.INFO_EMBED_COLOR - description = "" - for play in sorted(plays, key=lambda p: p["points"] or 0, reverse=True): - confirmed_str = "✅ " if play["confirmed_at"] is not None else "❌ " - points = play["points"] - points_str = f"{points} points" if points is not None else "not reported" - points_line = f"\n**ᅠ⮑ {confirmed_str}{points_str}**" - description += f"• <@{play['user_xid']}>{points_line}\n" - if any(play["confirmed_at"] is None for play in plays): - description += ( - "\nPlease confirm points with `/confirm` when all players have reported.\n" - ) - jump_links = game["jump_links"] - jump_link = jump_links[self.guild.id] - description += f"\n[Jump to game post]({jump_link})" - embed.description = description - return embed - - async def _update_posts(self, game: GameDict) -> None: - for post in game["posts"]: - guild_xid = post["guild_xid"] - channel_xid = post["channel_xid"] - message_xid = post["message_xid"] - channel = await safe_fetch_text_channel(self.bot, guild_xid, channel_xid) - if channel: - message = safe_get_partial_message(channel, guild_xid, message_xid) - if message: - embed = await self.services.games.to_embed() - await safe_update_embed(message, embed=embed) - - @tracer.wrap() - async def process(self, user_xid: int, points: int) -> None: - game = await self.services.games.select_last_ranked_game(user_xid) - if game is None: - await safe_send_channel(self.interaction, "No game found.", ephemeral=True) - return - - game_id = game["id"] - plays = await self.services.games.get_plays() - if plays.get(self.interaction.user.id, {}).get("confirmed_at", None): - await safe_send_channel( - self.interaction, - f"You've already confirmed your points for game SB{game_id}.", - ephemeral=True, - ) - return - - # if at least one player has confirmed their points, then changing points not allowed - if any(play["confirmed_at"] is not None for play in plays.values()): - await safe_send_channel( - self.interaction, - ( - f"Points for game SB{game_id} are locked in," - " please confirm them or contact a mod." - ), - ephemeral=True, - ) - return - - await self.services.games.add_points(user_xid, points) - plays[user_xid]["points"] = points - - embed = await self._report_embed(game, plays.values()) - await safe_send_channel(self.interaction, embed=embed, ephemeral=True) - await self._update_posts(game) - - @tracer.wrap() - async def loss(self) -> None: - await self.process(self.interaction.user.id, 0) - - @tracer.wrap() - async def win(self) -> None: - await self.process(self.interaction.user.id, 3) - - @tracer.wrap() - async def tie(self) -> None: - await self.process(self.interaction.user.id, 1) - - @tracer.wrap() - async def confirm(self) -> None: - user_xid = self.interaction.user.id - game = await self.services.games.select_last_ranked_game(user_xid) - if game is None: - await safe_send_channel(self.interaction, "No game found.", ephemeral=True) - return - plays = await self.services.games.get_plays() - for play in plays.values(): - if play["points"] is None: - embed = await self._report_embed(game, plays.values()) - await safe_send_channel( - self.interaction, - "You must wait until all players have reported their points.", - embed=embed, - ephemeral=True, - ) - return - confirmed_at = await self.services.games.confirm_points(user_xid) - plays[user_xid]["confirmed_at"] = confirmed_at - embed = await self._report_embed(game, plays.values()) - await safe_send_channel(self.interaction, embed=embed, ephemeral=True) - await self._update_posts(game) - if all(play["confirmed_at"] is not None for play in plays.values()): - await self.services.games.update_records(plays) - - @tracer.wrap() - async def check(self) -> None: - user_xid = self.interaction.user.id - game = await self.services.games.select_last_ranked_game(user_xid) - if game is None: - await safe_send_channel(self.interaction, "No game found.", ephemeral=True) - return - plays = await self.services.games.get_plays() - embed = await self._report_embed(game, plays.values()) - await safe_send_channel(self.interaction, embed=embed, ephemeral=True) - - @tracer.wrap() - async def elo(self) -> None: - if not self.interaction.guild: - await safe_send_channel( - self.interaction, - "This command can only be used in a server.", - ephemeral=True, - ) - return - if not self.interaction.channel: - await safe_send_channel( - self.interaction, - "This command can only be used in a channel.", - ephemeral=True, - ) - return - guild_xid = self.interaction.guild.id - channel_xid = self.interaction.channel.id - channel = await self.services.channels.select(channel_xid) - if not channel or not channel["require_confirmation"] or not channel["show_points"]: - await safe_send_channel( - self.interaction, - "ELO is not supported for this channel.", - ephemeral=True, - ) - return - user_xid = self.interaction.user.id - record = await self.services.games.get_record(guild_xid, channel_xid, user_xid) - await safe_send_channel(self.interaction, f"Your ELO is {record['elo']}.", ephemeral=True) diff --git a/src/spellbot/client.py b/src/spellbot/client.py index 1bbfab66..31d846d5 100644 --- a/src/spellbot/client.py +++ b/src/spellbot/client.py @@ -63,12 +63,11 @@ async def setup_hook(self) -> None: # pragma: no cover await initialize_connection("spellbot-bot") # register persistent views - from .views import PendingGameView, SetupView, StartedGameView, StartedGameViewWithConfirm + from .views import PendingGameView, SetupView, StartedGameView self.add_view(PendingGameView(self)) self.add_view(SetupView(self)) self.add_view(StartedGameView(self)) - self.add_view(StartedGameViewWithConfirm(self)) # load all cog extensions and application commands from .utils import load_extensions diff --git a/src/spellbot/cogs/__init__.py b/src/spellbot/cogs/__init__.py index fcdd61e6..60f8b4af 100644 --- a/src/spellbot/cogs/__init__.py +++ b/src/spellbot/cogs/__init__.py @@ -13,11 +13,8 @@ from .events_cog import EventsCog from .leave_cog import LeaveGameCog from .lfg_cog import LookingForGameCog -from .mod_cog import ModCog from .owner_cog import OwnerCog -from .record_cog import RecordCog from .score_cog import ScoreCog -from .sync_cog import SyncCog from .tasks_cog import TasksCog from .verify_cog import VerifyCog from .watch_cog import WatchCog @@ -32,11 +29,8 @@ "EventsCog", "LeaveGameCog", "LookingForGameCog", - "ModCog", "OwnerCog", - "RecordCog", "ScoreCog", - "SyncCog", "TasksCog", "VerifyCog", "WatchCog", diff --git a/src/spellbot/cogs/mod_cog.py b/src/spellbot/cogs/mod_cog.py deleted file mode 100644 index f44ac78c..00000000 --- a/src/spellbot/cogs/mod_cog.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging - -import discord -from ddtrace import tracer -from discord import app_commands -from discord.ext import commands - -from spellbot import SpellBot -from spellbot.actions import AdminAction -from spellbot.metrics import add_span_context -from spellbot.settings import settings -from spellbot.utils import for_all_callbacks, is_guild, is_mod - -logger = logging.getLogger(__name__) - - -@for_all_callbacks(app_commands.check(is_mod)) -@for_all_callbacks(app_commands.check(is_guild)) -class ModCog(commands.Cog): - def __init__(self, bot: SpellBot) -> None: - self.bot = bot - - mod_group = app_commands.Group(name="mod", description="...") - - @mod_group.command( - name="set_points", - description="Set points for a player's record.", - ) - @app_commands.describe(game_id="SpellBot ID of the game") - @app_commands.describe(player="The player to set points for") - @app_commands.describe(points="The points for the player") - @tracer.wrap(name="interaction", resource="mod_set_points") - async def mod_set_points( - self, - interaction: discord.Interaction, - game_id: int, - player: discord.User | discord.Member, - points: int, - ) -> None: - add_span_context(interaction) - async with AdminAction.create(self.bot, interaction) as action: - await action.set_points(game_id, player.id, points) - - -async def setup(bot: SpellBot) -> None: # pragma: no cover - await bot.add_cog(ModCog(bot), guild=settings.GUILD_OBJECT) diff --git a/src/spellbot/cogs/owner_cog.py b/src/spellbot/cogs/owner_cog.py index 7f45d9cb..3ab05a58 100644 --- a/src/spellbot/cogs/owner_cog.py +++ b/src/spellbot/cogs/owner_cog.py @@ -11,7 +11,7 @@ from spellbot.operations import bad_users, safe_send_user from spellbot.services import GuildsService, UsersService from spellbot.settings import settings -from spellbot.utils import for_all_callbacks +from spellbot.utils import for_all_callbacks, load_extensions logger = logging.getLogger(__name__) @@ -57,6 +57,21 @@ class OwnerCog(commands.Cog): def __init__(self, bot: SpellBot) -> None: self.bot = bot + @commands.command(name="sync") + @commands.is_owner() + @tracer.wrap(name="interaction", resource="sync") + async def sync(self, ctx: commands.Context[SpellBot]) -> None: + add_span_context(self.bot) + try: + await load_extensions(self.bot, do_sync=True) + await safe_send_user(ctx.message.author, "Commands synced!") + except Exception as ex: + try: + await safe_send_user(ctx.message.author, f"Error: {ex}") + except Exception: # pragma: no cover + logger.exception("Failed to send error message to user.") + await handle_exception(ex) + @commands.command(name="ban") @tracer.wrap(name="interaction", resource="ban") async def ban(self, ctx: commands.Context[SpellBot], arg: str | None = None) -> None: diff --git a/src/spellbot/cogs/record_cog.py b/src/spellbot/cogs/record_cog.py deleted file mode 100644 index b6e4781a..00000000 --- a/src/spellbot/cogs/record_cog.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging - -import discord -from ddtrace import tracer -from discord import app_commands -from discord.ext import commands - -from spellbot import SpellBot -from spellbot.actions.record_action import RecordAction -from spellbot.metrics import add_span_context -from spellbot.settings import settings -from spellbot.utils import for_all_callbacks, is_guild - -logger = logging.getLogger(__name__) - - -@for_all_callbacks(app_commands.check(is_guild)) -class RecordCog(commands.Cog): - def __init__(self, bot: SpellBot) -> None: - self.bot = bot - - @app_commands.command(name="loss", description="Record a loss for your last game.") - @tracer.wrap(name="interaction", resource="loss") - async def loss(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.loss() - - @app_commands.command(name="win", description="Record a win for your last game.") - @tracer.wrap(name="interaction", resource="win") - async def win(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.win() - - @app_commands.command(name="tie", description="Record a tie for your last game.") - @tracer.wrap(name="interaction", resource="tie") - async def tie(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.tie() - - @app_commands.command(name="confirm", description="Confirm your last game.") - @tracer.wrap(name="interaction", resource="confirm") - async def confirm(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.confirm() - - @app_commands.command(name="check", description="Check your last game.") - @tracer.wrap(name="interaction", resource="check") - async def check(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.check() - - @app_commands.command(name="elo", description="Check your current ELO.") - @tracer.wrap(name="interaction", resource="elo") - async def elo(self, interaction: discord.Interaction) -> None: - assert interaction.guild is not None - add_span_context(interaction) - async with RecordAction.create(self.bot, interaction) as action: - await action.elo() - - -async def setup(bot: SpellBot) -> None: # pragma: no cover - await bot.add_cog(RecordCog(bot), guild=settings.GUILD_OBJECT) diff --git a/src/spellbot/cogs/sync_cog.py b/src/spellbot/cogs/sync_cog.py deleted file mode 100644 index 554dc717..00000000 --- a/src/spellbot/cogs/sync_cog.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - -from ddtrace import tracer -from discord.ext import commands - -from spellbot import SpellBot -from spellbot.actions.base_action import handle_exception -from spellbot.metrics import add_span_context -from spellbot.operations import safe_send_user -from spellbot.settings import settings -from spellbot.utils import load_extensions - -logger = logging.getLogger(__name__) - - -class SyncCog(commands.Cog): - def __init__(self, bot: SpellBot) -> None: - self.bot = bot - - @commands.command(name="sync") - @commands.is_owner() - @tracer.wrap(name="interaction", resource="sync") - async def sync(self, ctx: commands.Context[SpellBot]) -> None: - add_span_context(self.bot) - try: - await load_extensions(self.bot, do_sync=True) - await safe_send_user(ctx.message.author, "Commands synced!") - except Exception as ex: - try: - await safe_send_user(ctx.message.author, f"Error: {ex}") - except Exception: # pragma: no cover - logger.exception("Failed to send error message to user.") - await handle_exception(ex) - - -async def setup(bot: SpellBot) -> None: # pragma: no cover - await bot.add_cog(SyncCog(bot), guild=settings.GUILD_OBJECT) diff --git a/src/spellbot/views/__init__.py b/src/spellbot/views/__init__.py index 309dbc32..7269a1a3 100644 --- a/src/spellbot/views/__init__.py +++ b/src/spellbot/views/__init__.py @@ -5,7 +5,6 @@ PendingGameView, StartedGameSelect, StartedGameView, - StartedGameViewWithConfirm, ) from .setup_view import SetupView @@ -15,5 +14,4 @@ "SetupView", "StartedGameSelect", "StartedGameView", - "StartedGameViewWithConfirm", ] diff --git a/src/spellbot/views/lfg_view.py b/src/spellbot/views/lfg_view.py index 1b7984f4..a6905b0b 100644 --- a/src/spellbot/views/lfg_view.py +++ b/src/spellbot/views/lfg_view.py @@ -108,59 +108,3 @@ async def callback(self, interaction: discord.Interaction) -> None: original_response = await safe_original_response(interaction) if original_response: await action.add_points(original_response, points) - - -class StartedGameSelectWinLoss(ui.Select[T]): - def __init__(self, bot: SpellBot) -> None: - self.bot = bot - super().__init__( - custom_id="points", - placeholder="Report game outcome", - options=[ - discord.SelectOption(label="Loss", value="0", emoji="❤️‍🩹"), - discord.SelectOption(label="Tie", value="1", emoji="🏅"), - discord.SelectOption(label="Win", value="3", emoji="🏆"), - ], - ) - - async def callback(self, interaction: discord.Interaction) -> None: - from spellbot.actions import LookingForGameAction - - with tracer.trace(name="interaction", resource="points"): - add_span_context(interaction) - await safe_defer_interaction(interaction) - assert interaction.original_response - points = int(self.values[0]) - async with LookingForGameAction.create(self.bot, interaction) as action: - original_response = await safe_original_response(interaction) - if original_response: - await action.add_points(original_response, points) - - -class StartedGameViewWithConfirm(BaseView): - def __init__(self, bot: SpellBot) -> None: - super().__init__(bot) - self.add_item(StartedGameSelectWinLoss[StartedGameViewWithConfirm](self.bot)) - self.add_item(StartedGameConfirm[StartedGameViewWithConfirm](self.bot)) - - -class StartedGameConfirm(ui.Button[T]): - def __init__(self, bot: SpellBot) -> None: - self.bot = bot - super().__init__( - custom_id="confirm", - emoji="✔️", - label="Confirm points", - style=discord.ButtonStyle.blurple, - ) - - async def callback(self, interaction: discord.Interaction) -> None: - from spellbot.actions import LookingForGameAction - - with tracer.trace(name="interaction", resource="confirm"): - add_span_context(interaction) - await safe_defer_interaction(interaction) - async with LookingForGameAction.create(self.bot, interaction) as action: - original_response = await safe_original_response(interaction) - if original_response: - await action.confirm_points(original_response) diff --git a/tests/cogs/test_leave_cog.py b/tests/cogs/test_leave_cog.py index 64946e1b..d1cb6f87 100644 --- a/tests/cogs/test_leave_cog.py +++ b/tests/cogs/test_leave_cog.py @@ -9,6 +9,7 @@ from spellbot.actions import leave_action from spellbot.cogs import LeaveGameCog from spellbot.database import DatabaseSession +from spellbot.models import Queue from spellbot.views.lfg_view import PendingGameView from tests.mixins import InteractionMixin from tests.mocks import mock_operations @@ -18,7 +19,7 @@ from freezegun.api import FrozenDateTimeFactory from spellbot.client import SpellBot - from spellbot.models import User + from spellbot.models import Game, User @pytest_asyncio.fixture @@ -33,7 +34,17 @@ async def use_consistent_date(freezer: FrozenDateTimeFactory) -> None: @pytest.mark.asyncio class TestCogLeaveGame(InteractionMixin): - async def test_leave(self, cog: LeaveGameCog, message: discord.Message, player: User) -> None: + async def test_leave( + self, + cog: LeaveGameCog, + message: discord.Message, + game: Game, + player: User, + ) -> None: + p2 = self.factories.user.create() + DatabaseSession.add(Queue(user_xid=p2.xid, game_id=game.id, og_guild_xid=game.guild_xid)) + DatabaseSession.commit() + with mock_operations(leave_action): leave_action.safe_fetch_text_channel.return_value = self.interaction.channel leave_action.safe_get_partial_message.return_value = message @@ -48,29 +59,56 @@ async def test_leave(self, cog: LeaveGameCog, message: discord.Message, player: leave_action.safe_update_embed.assert_called_once() safe_update_embed_call = leave_action.safe_update_embed.call_args_list[0] assert safe_update_embed_call.kwargs["embed"].to_dict() == { - "color": self.settings.EMPTY_EMBED_COLOR, + "color": self.settings.PENDING_EMBED_COLOR, "description": ( "_A SpellTable link will be created when all players have joined._\n" "\n" f"{self.guild.motd}\n\n{self.channel.motd}" ), "fields": [ + {"inline": False, "name": "Players", "value": f"• <@{p2.xid}> ({p2.name})"}, {"inline": True, "name": "Format", "value": "Commander"}, {"inline": True, "name": "Updated at", "value": ANY}, {"inline": False, "name": "Support SpellBot", "value": ANY}, ], "footer": {"text": f"SpellBot Game ID: #SB{self.game.id}"}, "thumbnail": {"url": self.settings.THUMB_URL}, - "title": "**Waiting for 4 more players to join...**", + "title": "**Waiting for 3 more players to join...**", "type": "rich", } + async def test_leave_then_delete( + self, + cog: LeaveGameCog, + message: discord.Message, + game: Game, + player: User, + ) -> None: + with mock_operations(leave_action): + leave_action.safe_fetch_text_channel.return_value = self.interaction.channel + leave_action.safe_get_partial_message.return_value = message + + await self.run(cog.leave_command) + + leave_action.safe_send_channel.assert_called_once_with( + self.interaction, + "You were removed from any pending games in this channel.", + ephemeral=True, + ) + leave_action.safe_update_embed.assert_not_called() + leave_action.safe_delete_message.assert_called_once_with(message) + async def test_leave_all( self, cog: LeaveGameCog, message: discord.Message, + game: Game, player: User, ) -> None: + p2 = self.factories.user.create() + DatabaseSession.add(Queue(user_xid=p2.xid, game_id=game.id, og_guild_xid=game.guild_xid)) + DatabaseSession.commit() + with mock_operations(leave_action): leave_action.safe_fetch_text_channel.return_value = self.interaction.channel leave_action.safe_get_partial_message.return_value = message @@ -85,23 +123,45 @@ async def test_leave_all( leave_action.safe_update_embed.assert_called_once() safe_update_embed_call = leave_action.safe_update_embed.call_args_list[0] assert safe_update_embed_call.kwargs["embed"].to_dict() == { - "color": self.settings.EMPTY_EMBED_COLOR, + "color": self.settings.PENDING_EMBED_COLOR, "description": ( "_A SpellTable link will be created when all players have joined._\n" "\n" f"{self.guild.motd}\n\n{self.channel.motd}" ), "fields": [ + {"inline": False, "name": "Players", "value": f"• <@{p2.xid}> ({p2.name})"}, {"inline": True, "name": "Format", "value": "Commander"}, {"inline": True, "name": "Updated at", "value": ANY}, {"inline": False, "name": "Support SpellBot", "value": ANY}, ], "footer": {"text": f"SpellBot Game ID: #SB{self.game.id}"}, "thumbnail": {"url": self.settings.THUMB_URL}, - "title": "**Waiting for 4 more players to join...**", + "title": "**Waiting for 3 more players to join...**", "type": "rich", } + async def test_leave_all_then_delete( + self, + cog: LeaveGameCog, + message: discord.Message, + game: Game, + player: User, + ) -> None: + with mock_operations(leave_action): + leave_action.safe_fetch_text_channel.return_value = self.interaction.channel + leave_action.safe_get_partial_message.return_value = message + + await self.run(cog.leave_all) + + leave_action.safe_send_channel.assert_called_once_with( + self.interaction, + "You were removed from all pending games.", + ephemeral=True, + ) + leave_action.safe_update_embed.assert_not_called() + leave_action.safe_delete_message.assert_called_once_with(message) + async def test_leave_all_when_no_games( self, cog: LeaveGameCog, @@ -224,8 +284,13 @@ async def test_leave_button( self, cog: LeaveGameCog, message: discord.Message, + game: Game, player: User, ) -> None: + p2 = self.factories.user.create() + DatabaseSession.add(Queue(user_xid=p2.xid, game_id=game.id, og_guild_xid=game.guild_xid)) + DatabaseSession.commit() + with mock_operations(leave_action): leave_action.safe_original_response.return_value = message view = PendingGameView(self.bot) @@ -235,23 +300,40 @@ async def test_leave_button( leave_action.safe_update_embed_origin.assert_called_once() safe_update_embed_origin_call = leave_action.safe_update_embed_origin.call_args_list[0] assert safe_update_embed_origin_call.kwargs["embed"].to_dict() == { - "color": self.settings.EMPTY_EMBED_COLOR, + "color": self.settings.PENDING_EMBED_COLOR, "description": ( "_A SpellTable link will be created when all players have joined._\n" "\n" f"{self.guild.motd}\n\n{self.channel.motd}" ), "fields": [ + {"inline": False, "name": "Players", "value": f"• <@{p2.xid}> ({p2.name})"}, {"inline": True, "name": "Format", "value": "Commander"}, {"inline": True, "name": "Updated at", "value": ANY}, {"inline": False, "name": "Support SpellBot", "value": ANY}, ], "footer": {"text": f"SpellBot Game ID: #SB{self.game.id}"}, "thumbnail": {"url": self.settings.THUMB_URL}, - "title": "**Waiting for 4 more players to join...**", + "title": "**Waiting for 3 more players to join...**", "type": "rich", } + async def test_leave_button_then_delete( + self, + cog: LeaveGameCog, + message: discord.Message, + game: Game, + player: User, + ) -> None: + with mock_operations(leave_action): + leave_action.safe_original_response.return_value = message + view = PendingGameView(self.bot) + + await view.leave.callback(self.interaction) + + leave_action.safe_update_embed_origin.assert_not_called() + leave_action.safe_delete_message.assert_called_once_with(self.interaction.message) + async def test_leave_button_when_no_game( self, cog: LeaveGameCog, @@ -281,25 +363,7 @@ async def test_leave_button_when_message_missing( await view.leave.callback(self.interaction) leave_action.safe_update_embed_origin.assert_not_called() - leave_action.safe_update_embed.assert_called_once() - safe_update_embed_call = leave_action.safe_update_embed.call_args_list[0] - assert safe_update_embed_call.kwargs["embed"].to_dict() == { - "color": self.settings.EMPTY_EMBED_COLOR, - "description": ( - "_A SpellTable link will be created when all players have joined._\n" - "\n" - f"{self.guild.motd}\n\n{self.channel.motd}" - ), - "fields": [ - {"inline": True, "name": "Format", "value": "Commander"}, - {"inline": True, "name": "Updated at", "value": ANY}, - {"inline": False, "name": "Support SpellBot", "value": ANY}, - ], - "footer": {"text": f"SpellBot Game ID: #SB{self.game.id}"}, - "thumbnail": {"url": self.settings.THUMB_URL}, - "title": "**Waiting for 4 more players to join...**", - "type": "rich", - } + leave_action.safe_delete_message.assert_called_once_with(message) async def test_leave_button_when_message_missing_and_fetch_channel_fails( self, diff --git a/tests/cogs/test_mod_cog.py b/tests/cogs/test_mod_cog.py deleted file mode 100644 index 5dc11152..00000000 --- a/tests/cogs/test_mod_cog.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import TYPE_CHECKING - -import pytest -import pytest_asyncio -import pytz - -from spellbot.actions import admin_action -from spellbot.cogs import ModCog -from spellbot.database import DatabaseSession -from spellbot.models import GameStatus, Play -from tests.mixins import InteractionMixin -from tests.mocks import mock_discord_object, mock_operations - -if TYPE_CHECKING: - from spellbot.client import SpellBot - - -@pytest_asyncio.fixture -async def cog(bot: SpellBot) -> ModCog: - return ModCog(bot) - - -@pytest.mark.asyncio -class TestCogModSetPoints(InteractionMixin): - async def test_happy_path(self, cog: ModCog) -> None: - guild = self.factories.guild.create() - channel = self.factories.channel.create(guild=guild) - game = self.factories.game.create( - guild=guild, - channel=channel, - seats=2, - status=GameStatus.STARTED.value, - started_at=datetime(2021, 10, 31, tzinfo=pytz.utc), - ) - user1 = self.factories.user.create(game=game) - user2 = self.factories.user.create(game=game) - player1 = mock_discord_object(user1) - player2 = mock_discord_object(user2) - points = 3 - - with mock_operations(admin_action, users=[player1, player2]): - await self.run(cog.mod_set_points, game_id=game.id, player=player1, points=points) - admin_action.safe_send_channel.assert_called_once_with( - self.interaction, - f"Points for <@{player1.id}> for game SB{game.id} set to {points}.", - ephemeral=True, - ) - admin_action.safe_update_embed.assert_called_once() - - play1 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user1.xid).one() - play2 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user2.xid).one() - assert play1.points == points - assert play2.points is None - - async def test_missing_game(self, cog: ModCog) -> None: - user = self.factories.user.create() - player = mock_discord_object(user) - - with mock_operations(admin_action, users=[player]): - await self.run(cog.mod_set_points, game_id=404, player=player, points=1) - admin_action.safe_send_channel.assert_called_once_with( - self.interaction, - "There is no game with that ID.", - ephemeral=True, - ) - - async def test_missing_player(self, cog: ModCog) -> None: - guild = self.factories.guild.create() - channel = self.factories.channel.create(guild=guild) - game = self.factories.game.create( - guild=guild, - channel=channel, - seats=2, - status=GameStatus.STARTED.value, - started_at=datetime(2021, 10, 31, tzinfo=pytz.utc), - ) - user = self.factories.user.create() - player = mock_discord_object(user) - - with mock_operations(admin_action, users=[player]): - await self.run(cog.mod_set_points, game_id=game.id, player=player, points=1) - admin_action.safe_send_channel.assert_called_once_with( - self.interaction, - f"User <@{user.xid}> did not play in game SB{game.id}.", - ephemeral=True, - ) - - async def test_channel_fetch_error(self, cog: ModCog) -> None: - guild = self.factories.guild.create() - channel = self.factories.channel.create(guild=guild) - game = self.factories.game.create( - guild=guild, - channel=channel, - seats=2, - status=GameStatus.STARTED.value, - started_at=datetime(2021, 10, 31, tzinfo=pytz.utc), - ) - user1 = self.factories.user.create(game=game) - user2 = self.factories.user.create(game=game) - player1 = mock_discord_object(user1) - player2 = mock_discord_object(user2) - points = 3 - - with mock_operations(admin_action, users=[player1, player2]): - admin_action.safe_fetch_text_channel.return_value = None - await self.run(cog.mod_set_points, game_id=game.id, player=player1, points=points) - admin_action.safe_send_channel.assert_called_once_with( - self.interaction, - f"Points for <@{player1.id}> for game SB{game.id} set to {points}.", - ephemeral=True, - ) - admin_action.safe_update_embed.assert_not_called() - - play1 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user1.xid).one() - play2 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user2.xid).one() - assert play1.points == points - assert play2.points is None - - async def test_message_fetch_error(self, cog: ModCog) -> None: - guild = self.factories.guild.create() - channel = self.factories.channel.create(guild=guild) - game = self.factories.game.create( - guild=guild, - channel=channel, - seats=2, - status=GameStatus.STARTED.value, - started_at=datetime(2021, 10, 31, tzinfo=pytz.utc), - ) - user1 = self.factories.user.create(game=game) - user2 = self.factories.user.create(game=game) - player1 = mock_discord_object(user1) - player2 = mock_discord_object(user2) - points = 3 - - with mock_operations(admin_action, users=[player1, player2]): - admin_action.safe_get_partial_message.return_value = None - await self.run(cog.mod_set_points, game_id=game.id, player=player1, points=points) - admin_action.safe_send_channel.assert_called_once_with( - self.interaction, - f"Points for <@{player1.id}> for game SB{game.id} set to {points}.", - ephemeral=True, - ) - admin_action.safe_update_embed.assert_not_called() - - play1 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user1.xid).one() - play2 = DatabaseSession.query(Play).filter_by(game_id=game.id, user_xid=user2.xid).one() - assert play1.points == points - assert play2.points is None diff --git a/tests/cogs/test_owner_cog.py b/tests/cogs/test_owner_cog.py index b763a182..0c0dae96 100644 --- a/tests/cogs/test_owner_cog.py +++ b/tests/cogs/test_owner_cog.py @@ -164,3 +164,25 @@ async def test_naughty(self, mocker: MockerFixture) -> None: self.context.author.send.assert_called_once_with( "Naughty users: <@1> (1)\n<@2> (2)\n<@3> (3)", ) + + async def test_sync(self, mocker: MockerFixture) -> None: + mocker.patch("spellbot.cogs.sync_cog.load_extensions", AsyncMock()) + cog = OwnerCog(self.bot) + callback = partial(cog.sync.callback, cog) + + await callback(self.context) + + self.context.author.send.assert_called_once_with("Commands synced!") + + async def test_sync_exception(self, mocker: MockerFixture) -> None: + mocker.patch( + "spellbot.cogs.sync_cog.load_extensions", + AsyncMock(side_effect=RuntimeError("oops")), + ) + cog = OwnerCog(self.bot) + callback = partial(cog.sync.callback, cog) + + with pytest.raises(RuntimeError): + await callback(self.context) + + self.context.author.send.assert_called_once_with("Error: oops") diff --git a/tests/cogs/test_sync_cog.py b/tests/cogs/test_sync_cog.py deleted file mode 100644 index 572b2770..00000000 --- a/tests/cogs/test_sync_cog.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from functools import partial -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock - -import pytest - -from spellbot.cogs import SyncCog -from tests.mixins import ContextMixin - -if TYPE_CHECKING: - from pytest_mock import MockerFixture - - -@pytest.mark.asyncio -class TestCogSync(ContextMixin): - async def test_sync(self, mocker: MockerFixture) -> None: - mocker.patch("spellbot.cogs.sync_cog.load_extensions", AsyncMock()) - cog = SyncCog(self.bot) - callback = partial(cog.sync.callback, cog) - - await callback(self.context) - - self.context.author.send.assert_called_once_with("Commands synced!") - - async def test_sync_exception(self, mocker: MockerFixture) -> None: - mocker.patch( - "spellbot.cogs.sync_cog.load_extensions", - AsyncMock(side_effect=RuntimeError("oops")), - ) - cog = SyncCog(self.bot) - callback = partial(cog.sync.callback, cog) - - with pytest.raises(RuntimeError): - await callback(self.context) - - self.context.author.send.assert_called_once_with("Error: oops")