From 891b7cbf1b2b1eea0bdddcaa6744adee8cf9d4bb Mon Sep 17 00:00:00 2001 From: lexicalunit Date: Mon, 19 Jan 2026 19:42:08 -0800 Subject: [PATCH] Include player data in call to Convoke --- CHANGELOG.md | 4 ++++ src/spellbot/integrations/convoke.py | 11 +++++------ src/spellbot/services/apps.py | 5 +++-- src/spellbot/services/games.py | 12 ++++++++++++ tests/actions/test_lfg_action.py | 7 +++++++ tests/services/test_apps.py | 6 ++++++ tests/services/test_games.py | 20 +++++++++++--------- 7 files changed, 48 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fb2b38..d36cce8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Added discordPlayers to Convoke game creation. + ## [v17.8.4](https://github.com/lexicalunit/spellbot/releases/tag/v17.8.4) - 2026-01-19 ### Fixed diff --git a/src/spellbot/integrations/convoke.py b/src/spellbot/integrations/convoke.py index ab55eba8..52110517 100644 --- a/src/spellbot/integrations/convoke.py +++ b/src/spellbot/integrations/convoke.py @@ -10,12 +10,14 @@ from spellbot import __version__ from spellbot.enums import GameBracket, GameFormat from spellbot.metrics import add_span_error +from spellbot.services import ServicesRegistry from spellbot.settings import settings if TYPE_CHECKING: from spellbot.models import GameDict logger = logging.getLogger(__name__) +services = ServicesRegistry() USE_PASSWORD = False # no password is now supported! RETRY_ATTEMPTS = 2 @@ -113,6 +115,7 @@ async def fetch_convoke_link( # pragma: no cover name = f"SB{game['id']}" sb_game_format = GameFormat(game["format"]) format = convoke_game_format(sb_game_format).value + players = await services.games.player_data(game["id"]) payload = { "apiKey": settings.CONVOKE_API_KEY, "isPublic": False, @@ -121,6 +124,7 @@ async def fetch_convoke_link( # pragma: no cover "format": format, "discordGuild": str(game["guild_xid"]), "discordChannel": str(game["channel_xid"]), + "discordPlayers": [{"id": str(p["xid"]), "name": p["name"]} for p in players], } if game["bracket"] != GameBracket.NONE.value: payload["bracketLevel"] = f"B{game['bracket'] - 1}" @@ -140,12 +144,7 @@ async def generate_link( return None, None key = passphrase() - timeout = httpx.Timeout( - TIMEOUT_S, - connect=TIMEOUT_S, - read=TIMEOUT_S, - write=TIMEOUT_S, - ) + timeout = httpx.Timeout(TIMEOUT_S, connect=TIMEOUT_S, read=TIMEOUT_S, write=TIMEOUT_S) data: dict[str, Any] | None = None async with httpx.AsyncClient(timeout=timeout) as client: for attempt in range(RETRY_ATTEMPTS): diff --git a/src/spellbot/services/apps.py b/src/spellbot/services/apps.py index 10e90d77..37a86800 100644 --- a/src/spellbot/services/apps.py +++ b/src/spellbot/services/apps.py @@ -18,8 +18,9 @@ def verify_token(self, key: str, path: str) -> bool: return False if token.scopes == "*": return True - required_scope = path.lstrip("/").split("/")[1] - if not required_scope: + try: + required_scope = path.lstrip("/").split("/")[1] + except IndexError: return False scopes_list = token.scopes.split(",") return required_scope in scopes_list diff --git a/src/spellbot/services/games.py b/src/spellbot/services/games.py index 6ae82745..fb476afd 100644 --- a/src/spellbot/services/games.py +++ b/src/spellbot/services/games.py @@ -22,7 +22,9 @@ Post, Queue, QueueDict, + User, UserAward, + UserDict, Watch, ) from spellbot.settings import settings @@ -566,3 +568,13 @@ def select_last_game(self, user_xid: int, guild_xid: int) -> GameDict | None: .first() ) return self.game.to_dict() if self.game else None + + @sync_to_async() + @tracer.wrap() + def player_data(self, game_id: int) -> list[UserDict]: + game = DatabaseSession.query(Game).filter(Game.id == game_id).first() + if not game: + return [] + player_xids = game.player_xids + players = DatabaseSession.query(User).filter(User.xid.in_(player_xids)).all() + return [p.to_dict() for p in players] diff --git a/tests/actions/test_lfg_action.py b/tests/actions/test_lfg_action.py index 878c70fa..72d9e279 100644 --- a/tests/actions/test_lfg_action.py +++ b/tests/actions/test_lfg_action.py @@ -1099,3 +1099,10 @@ async def test_handle_direct_messages_award_success( add_role_stub.assert_called_once() # Should send award message assert send_stub.call_count >= 2 # One for game DM, one for award message + + async def test_get_bracket_when_no_format_no_bracket_no_default( + self, + action: LookingForGameAction, + ) -> None: + action.channel_data = {"default_bracket": None} # type: ignore + assert await action.get_bracket(None, None) == GameBracket.NONE.value diff --git a/tests/services/test_apps.py b/tests/services/test_apps.py index 1abfc744..9cf5c1ca 100644 --- a/tests/services/test_apps.py +++ b/tests/services/test_apps.py @@ -55,3 +55,9 @@ async def test_verify_token_empty_required_scope(self, factories: Factories) -> # Let's test with a path that produces an empty string at index 1 # "//game" -> ["", "", "game"] -> [1] is "" which triggers the check assert await apps.verify_token(token.key, "//game/1") is False + + async def test_verify_token_when_path_is_bad(self, factories: Factories) -> None: + """Test that a bad path doesn't crash and returns False.""" + apps = AppsService() + token = factories.token.create(key="key", scopes="game") + assert await apps.verify_token(token.key, "/bogus") is False diff --git a/tests/services/test_games.py b/tests/services/test_games.py index 2448a32a..0cd5fb19 100644 --- a/tests/services/test_games.py +++ b/tests/services/test_games.py @@ -7,15 +7,7 @@ from spellbot.database import DatabaseSession from spellbot.enums import GameBracket, GameFormat, GameService -from spellbot.models import ( - Channel, - Game, - GameStatus, - Guild, - Post, - Queue, - User, -) +from spellbot.models import Channel, Game, GameStatus, Guild, Post, Queue, User from spellbot.services import GamesService from tests.factories import ( BlockFactory, @@ -176,6 +168,16 @@ async def test_dequeue_players(self, game: Game) -> None: assert user1.game(game.channel_xid) is None assert user2.game(game.channel_xid) is None + async def test_player_data(self, game: Game) -> None: + user1 = UserFactory.create(game=game) + user2 = UserFactory.create(game=game) + games = GamesService() + assert await games.player_data(game.id) == [user1.to_dict(), user2.to_dict()] + + async def test_player_data_when_game_not_found(self) -> None: + games = GamesService() + assert await games.player_data(404) == [] + @pytest.mark.asyncio class TestServiceGamesPlays: