From e76f4ab7e665baa681dc9694419f604bd1e4b4a6 Mon Sep 17 00:00:00 2001 From: lexicalunit Date: Sat, 30 Nov 2024 16:54:31 -0800 Subject: [PATCH] Adds Table Stream support --- CHANGELOG.md | 4 + README.md | 3 +- docs/index.html | 4 +- pyproject.toml | 1 + src/spellbot/actions/lfg_action.py | 4 +- src/spellbot/client.py | 8 +- src/spellbot/cogs/admin_cog.py | 6 +- src/spellbot/cogs/events_cog.py | 15 +- src/spellbot/enums.py | 2 +- ...0a7382dbcae6_adds_game_password_support.py | 24 ++++ src/spellbot/models/game.py | 19 ++- src/spellbot/services/games.py | 9 +- src/spellbot/tablestream.py | 132 +++++++++++++----- tests/models/test_game.py | 1 + tests/services/test_games.py | 3 +- tests/test_bot.py | 3 +- 16 files changed, 173 insertions(+), 65 deletions(-) create mode 100644 src/spellbot/migrations/versions/0a7382dbcae6_adds_game_password_support.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 624e40b7..d0977780 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 + +- Adds support for Table Stream. + ### Changed - Updated all dependencies. diff --git a/README.md b/README.md index 625f7928..1d14b2bb 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The Discord bot for SpellTable ## 🤖 Using SpellBot -SpellBot helps you find _Magic: The Gathering_ games on [SpellTable][spelltable]. Just looking to +SpellBot helps you find _Magic: The Gathering_ games on [SpellTable][spelltable] or [Table Stream][tablestream]. Just looking to play a game of Commander? Run the command `/lfg` and SpellBot will help you out!

@@ -185,5 +185,6 @@ Any usage of SpellBot implies that you accept the following policies. [ruff]: https://github.com/astral-sh/ruff [slash]: https://discord.com/blog/slash-commands-are-here [spelltable]: https://spelltable.wizards.com/ +[tablestream]: https://table-stream.com/ [uptime-badge]: https://img.shields.io/uptimerobot/ratio/m785764282-c51c742e56a87d802968efcc [uptime]: https://uptimerobot.com/dashboard#785764282 diff --git a/docs/index.html b/docs/index.html index 0ff9bdd7..4e0521c9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,7 +3,7 @@ title: SpellBot subtitle: The Discord bot for SpellTable use-site-title: true -description: "SpellBot is the Discord bot that helps you find other players looking to play Magic: The Gathering on SpellTable." +description: "SpellBot is the Discord bot that helps you find other players looking to play Magic: The Gathering on SpellTable or Table Stream." ---

@@ -31,7 +31,7 @@

- SpellBot helps you find Magic: The Gathering games on SpellTable. Just looking to play a game of Commander? Run the command /lfg and SpellBot will help you out! + SpellBot helps you find Magic: The Gathering games on SpellTable or Table Stream. Just looking to play a game of Commander? Run the command /lfg and SpellBot will help you out!

/lfg
diff --git a/pyproject.toml b/pyproject.toml index 44db300a..1c85ed55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [tool.pyright] enableTypeIgnoreComments = true +reportMatchNotExhaustive = true reportMissingParameterType = true reportMissingTypeArgument = true reportUnnecessaryTypeIgnoreComment = "error" diff --git a/src/spellbot/actions/lfg_action.py b/src/spellbot/actions/lfg_action.py index eea3664a..5447fef1 100644 --- a/src/spellbot/actions/lfg_action.py +++ b/src/spellbot/actions/lfg_action.py @@ -406,8 +406,8 @@ async def create_game( @tracer.wrap() async def make_game_ready(self, game: GameDict) -> int: - game_link = await self.bot.create_game_link(game) - return await self.services.games.make_ready(game_link) + game_link, password = await self.bot.create_game_link(game) + return await self.services.games.make_ready(game_link, password) @tracer.wrap() async def _handle_voice_creation(self, guild_xid: int) -> None: diff --git a/src/spellbot/client.py b/src/spellbot/client.py index c2075aa3..1bbfab66 100644 --- a/src/spellbot/client.py +++ b/src/spellbot/client.py @@ -87,15 +87,15 @@ async def guild_lock(self, guild_xid: int) -> AsyncGenerator[None, None]: yield @tracer.wrap() - async def create_game_link(self, game: GameDict) -> str | None: + async def create_game_link(self, game: GameDict) -> tuple[str | None, str | None]: if self.mock_games: - return f"http://exmaple.com/game/{uuid4()}" + return f"http://exmaple.com/game/{uuid4()}", None service = game.get("service") if service == GameService.SPELLTABLE.value: - return await generate_spelltable_link(game) + return await generate_spelltable_link(game), None if service == GameService.TABLE_STREAM.value: return await generate_tablestream_link(game) - return None + return None, None @tracer.wrap(name="interaction", resource="on_message") async def on_message( diff --git a/src/spellbot/cogs/admin_cog.py b/src/spellbot/cogs/admin_cog.py index 2233e6dc..a899fa88 100644 --- a/src/spellbot/cogs/admin_cog.py +++ b/src/spellbot/cogs/admin_cog.py @@ -8,7 +8,7 @@ from spellbot import SpellBot from spellbot.actions import AdminAction -from spellbot.enums import GameFormat, GameService +from spellbot.enums import GAME_FORMAT_ORDER, GAME_SERVICE_ORDER from spellbot.metrics import add_span_context from spellbot.settings import settings from spellbot.utils import for_all_callbacks, is_admin, is_guild @@ -168,7 +168,7 @@ async def default_seats(self, interaction: discord.Interaction, seats: int) -> N ) @app_commands.describe(format="Default game format") @app_commands.choices( - format=[Choice(name=str(format), value=format.value) for format in GameFormat], + format=[Choice(name=str(format), value=format.value) for format in GAME_FORMAT_ORDER], ) @tracer.wrap(name="interaction", resource="set_default_format") async def default_format(self, interaction: discord.Interaction, format: int) -> None: @@ -182,7 +182,7 @@ async def default_format(self, interaction: discord.Interaction, format: int) -> ) @app_commands.describe(service="Default service") @app_commands.choices( - service=[Choice(name=str(service), value=service.value) for service in GameService], + service=[Choice(name=str(service), value=service.value) for service in GAME_SERVICE_ORDER], ) @tracer.wrap(name="interaction", resource="set_default_service") async def default_service(self, interaction: discord.Interaction, service: int) -> None: diff --git a/src/spellbot/cogs/events_cog.py b/src/spellbot/cogs/events_cog.py index b86489a2..cb3de838 100644 --- a/src/spellbot/cogs/events_cog.py +++ b/src/spellbot/cogs/events_cog.py @@ -8,7 +8,7 @@ from spellbot import SpellBot from spellbot.actions import LookingForGameAction -from spellbot.enums import GameFormat +from spellbot.enums import GAME_FORMAT_ORDER, GAME_SERVICE_ORDER from spellbot.metrics import add_span_context from spellbot.operations import safe_defer_interaction from spellbot.settings import settings @@ -24,10 +24,14 @@ def __init__(self, bot: SpellBot) -> None: self.bot = bot @app_commands.command(name="game", description="Immediately create and start an ad-hoc game.") - @app_commands.describe(players="Mention all players for this game.") - @app_commands.describe(format="What game format? Default if unspecified is Commander.") + @app_commands.describe(players="You must mention ALL players for this game.") + @app_commands.describe(format="What game format do you want to play?") @app_commands.choices( - format=[Choice(name=str(format), value=format.value) for format in GameFormat], + format=[Choice(name=str(format), value=format.value) for format in GAME_FORMAT_ORDER], + ) + @app_commands.describe(service="What service do you want to use to play this game?") + @app_commands.choices( + service=[Choice(name=str(service), value=service.value) for service in GAME_SERVICE_ORDER], ) @tracer.wrap(name="interaction", resource="game") async def game( @@ -35,12 +39,13 @@ async def game( interaction: discord.Interaction, players: str, format: int | None = None, + service: int | None = None, ) -> None: assert interaction.channel_id is not None add_span_context(interaction) await safe_defer_interaction(interaction) async with LookingForGameAction.create(self.bot, interaction) as action: - await action.create_game(players, format) + await action.create_game(players, format, service) async def setup(bot: SpellBot) -> None: # pragma: no cover diff --git a/src/spellbot/enums.py b/src/spellbot/enums.py index fb43f399..6d567581 100644 --- a/src/spellbot/enums.py +++ b/src/spellbot/enums.py @@ -39,7 +39,7 @@ def __str__(self) -> str: GAME_SERVICE_ORDER = [ GameService.NOT_ANY, GameService.SPELLTABLE, - # GameService.TABLE_STREAM, # Not currently supported, coming soon! + GameService.TABLE_STREAM, GameService.COCKATRICE, GameService.X_MAGE, GameService.MTG_ARENA, diff --git a/src/spellbot/migrations/versions/0a7382dbcae6_adds_game_password_support.py b/src/spellbot/migrations/versions/0a7382dbcae6_adds_game_password_support.py new file mode 100644 index 00000000..6cb78a02 --- /dev/null +++ b/src/spellbot/migrations/versions/0a7382dbcae6_adds_game_password_support.py @@ -0,0 +1,24 @@ +""" +Adds game password support. + +Revision ID: 0a7382dbcae6 +Revises: 053fd7a31881 +Create Date: 2024-11-30 16:31:51.016143 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0a7382dbcae6" +down_revision = "053fd7a31881" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("games", sa.Column("password", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column("games", "password") diff --git a/src/spellbot/models/game.py b/src/spellbot/models/game.py index 18dd6a23..46233313 100644 --- a/src/spellbot/models/game.py +++ b/src/spellbot/models/game.py @@ -44,6 +44,7 @@ class GameDict(TypedDict): jump_links: dict[int, str] confirmed: bool requires_confirmation: bool + password: str | None class Game(Base): @@ -151,6 +152,7 @@ class Game(Base): ), ) spelltable_link = Column(String(255), doc="The generated SpellTable link for this game") + password = Column(String(255), nullable=True, doc="The password for this game") voice_invite_link = Column(String(255), doc="The voice channel invite link for this game") requires_confirmation = Column( Boolean, @@ -225,6 +227,8 @@ def embed_description(self, dm: bool = False) -> str: # noqa: C901,PLR0912 if self.status == GameStatus.PENDING.value: if self.service == GameService.SPELLTABLE.value: description += "_A SpellTable link will be created when all players have joined._" + elif self.service == GameService.TABLE_STREAM.value: + description += "_A Table Stream link will be created when all players have joined._" elif self.service == GameService.NOT_ANY.value: description += "_Please contact the players in your game to organize this game._" else: @@ -233,20 +237,28 @@ def embed_description(self, dm: bool = False) -> str: # noqa: C901,PLR0912 if self.show_links(dm): if self.spelltable_link: description += ( - f"[Join your SpellTable game now!]({self.spelltable_link})" - f" (or [spectate this game]({self.spectate_link}))" + f"[Join your {GameService(self.service)} game now!]({self.spelltable_link})" ) + if self.service == GameService.SPELLTABLE.value: + description += f" (or [spectate this game]({self.spectate_link}))" elif self.service == GameService.SPELLTABLE.value: description += ( "Sorry but SpellBot was unable to create a SpellTable link" " for this game. Please go to [SpellTable]" "(https://spelltable.wizards.com/) to create one." ) + elif self.service == GameService.TABLE_STREAM.value: + description += ( + "Sorry but SpellBot was unable to create a Table Stream link" + " for this game. Please go to [Table Stream]" + "(https://table-stream.com/) to create one." + ) elif self.service != GameService.NOT_ANY.value: description += f"Please use {GameService(self.service)} to play this game." else: description += "Contact the other players in your game to organize this match." - + if self.password: + description += f"\n\nPassword: `{self.password}`" if self.voice_xid: description += f"\n\nJoin your voice chat now: <#{self.voice_xid}>" if self.voice_invite_link: @@ -415,4 +427,5 @@ def to_dict(self) -> GameDict: "jump_links": self.jump_links, "confirmed": self.confirmed, "requires_confirmation": self.channel.require_confirmation, + "password": self.password, } diff --git a/src/spellbot/services/games.py b/src/spellbot/services/games.py index 80743a64..7008a149 100644 --- a/src/spellbot/services/games.py +++ b/src/spellbot/services/games.py @@ -39,7 +39,7 @@ logger = logging.getLogger(__name__) -MAX_SPELLTABLE_LINK_LEN = Game.spelltable_link.property.columns[0].type.length # type: ignore +MAX_GAME_LINK_LEN = Game.spelltable_link.property.columns[0].type.length # type: ignore class GamesService: @@ -337,16 +337,17 @@ def other_game_ids(self) -> list[int]: @sync_to_async() @tracer.wrap() - def make_ready(self, spelltable_link: str | None) -> int: + def make_ready(self, game_link: str | None, password: str | None) -> int: assert self.game - assert len(spelltable_link or "") <= MAX_SPELLTABLE_LINK_LEN + assert len(game_link or "") <= MAX_GAME_LINK_LEN queues: list[QueueDict] = [ queue.to_dict() for queue in DatabaseSession.query(Queue).filter(Queue.game_id == self.game.id).all() ] # update game's state - self.game.spelltable_link = spelltable_link + self.game.spelltable_link = game_link # column is "spelltable_link" for legacy reasons + self.game.password = password self.game.status = GameStatus.STARTED.value self.game.started_at = datetime.now(tz=pytz.utc) diff --git a/src/spellbot/tablestream.py b/src/spellbot/tablestream.py index 0962ab6a..59fd6e71 100644 --- a/src/spellbot/tablestream.py +++ b/src/spellbot/tablestream.py @@ -2,12 +2,14 @@ import json import logging -from typing import TYPE_CHECKING, Any +from enum import Enum +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict from aiohttp.client_exceptions import ClientError from aiohttp_retry import ExponentialRetry, RetryClient from spellbot import __version__ +from spellbot.enums import GameFormat from spellbot.metrics import add_span_error from spellbot.settings import settings @@ -17,7 +19,67 @@ logger = logging.getLogger(__name__) -async def generate_tablestream_link(game: GameDict) -> str | None: +class TableSteamGameTypes(Enum): + MTGCommander = "MTGCommander" + MTGLegacy = "MTGLegacy" + MTGModern = "MTGModern" + MTGStandard = "MTGStandard" + MTGVintage = "MTGVintage" + + +def table_stream_game_type(format: GameFormat) -> TableSteamGameTypes: + match format: + case ( + GameFormat.COMMANDER + | GameFormat.OATHBREAKER + | GameFormat.BRAWL_MULTIPLAYER + | GameFormat.EDH_MAX + | GameFormat.EDH_HIGH + | GameFormat.EDH_MID + | GameFormat.EDH_LOW + | GameFormat.EDH_BATTLECRUISER + | GameFormat.PLANECHASE + | GameFormat.TWO_HEADED_GIANT + | GameFormat.PRE_CONS + | GameFormat.CEDH + | GameFormat.PAUPER_EDH + | GameFormat.ARCHENEMY + ): + return TableSteamGameTypes.MTGCommander + case ( + GameFormat.LEGACY + | GameFormat.PAUPER + | GameFormat.DUEL_COMMANDER + | GameFormat.BRAWL_TWO_PLAYER + ): + return TableSteamGameTypes.MTGLegacy + case GameFormat.MODERN | GameFormat.PIONEER: + return TableSteamGameTypes.MTGModern + case GameFormat.STANDARD | GameFormat.SEALED: + return TableSteamGameTypes.MTGStandard + case GameFormat.VINTAGE: + return TableSteamGameTypes.MTGVintage + + +class TableStreamArgs(TypedDict): + roomName: str + gameType: str + maxPlayers: int + + # If true and no password passed in the system + # will auto generate and return a password + private: NotRequired[bool] + + # password for the room, leave blank for auto + # generated password if 'private' is true + password: NotRequired[bool] + + # amount of time the room is joinable after + # which it is auto deleted. Default: 1 hour + initialScheduleTTLInSeconds: NotRequired[int] + + +async def generate_tablestream_link(game: GameDict) -> tuple[str | None, str | None]: assert settings.TABLESTREAM_AUTH_KEY headers = { @@ -28,25 +90,16 @@ async def generate_tablestream_link(game: GameDict) -> str | None: data: dict[str, Any] | None = None raw_data: bytes | None = None - # Game Types: - # "MTGCommander" - # "MTGStandard" - # "MTGModern" - # "MTGVintage" - # "MTGLegacy" - - request_data = { - "roomName": "amy-testing", - "gameType": "MTGCommander", - "maxPlayers": 4, - "private": True, - # private?:boolean; optional: If true and no password passed in the system - # will auto generate and return a password - # password?:string; optional: password for the room, leave blank for auto - # generated password if 'private' is true - # initialScheduleTTLInSeconds?:number; optional: amount of time the room is joinable after - # which it is auto deleted. Default: 1 hour - } + room_name = f"SB{game['id']}" + sb_game_format = GameFormat(game["format"]) + ts_game_type = table_stream_game_type(sb_game_format).value + ts_args = TableStreamArgs( + roomName=room_name, + gameType=ts_game_type, + maxPlayers=sb_game_format.players, + private=True, + initialScheduleTTLInSeconds=1 * 60 * 60, # 1 hour + ) try: async with ( @@ -57,27 +110,30 @@ async def generate_tablestream_link(game: GameDict) -> str | None: client.post( settings.TABLESTREAM_CREATE, headers=headers, - json=request_data, + json={**ts_args}, ) as resp, ): # Rather than use `resp.json()`, which respects mimetype, let's just # grab the data and try to decode it ourselves. # https://github.com/inyutin/aiohttp_retry/issues/55 raw_data = await resp.read() - data = json.loads(raw_data) - - # data = { - # "room": { - # "roomName": "amy-testing", - # "roomId": "cc56f322-3338-4643-8cd3-251055aa515d", - # "roomUrl": "https://table-stream.com/game?id=cc56f322-3338-4643-8cd3-251055aa515d", - # "gameType": "MTGCommander", - # "maxPlayers": 4, - # "password": "J^vT!wL1kQ", - # } - # } - return None + if not (data := json.loads(raw_data)): + return None, None + + # Example response: + # { + # "room": { + # "roomName": "SB12345", + # "roomId": "UUID", + # "roomUrl": "https://table-stream.com/game?id=UUID", + # "gameType": "MTGCommander", + # "maxPlayers": 4, + # "password": "OMIT", + # } + # } + room = data.get("room", {}) + return room.get("roomUrl"), room.get("password") except ClientError as ex: add_span_error(ex) logger.warning( @@ -87,11 +143,11 @@ async def generate_tablestream_link(game: GameDict) -> str | None: raw_data, exc_info=True, ) - return None + return None, None except Exception as ex: if raw_data == b"upstream request timeout": - return None + return None, None add_span_error(ex) logger.exception("error: unexpected exception: data: %s, raw: %s", data, raw_data) - return None + return None, None diff --git a/tests/models/test_game.py b/tests/models/test_game.py index 2905218e..da8bc9cd 100644 --- a/tests/models/test_game.py +++ b/tests/models/test_game.py @@ -50,6 +50,7 @@ def test_game_to_dict(self, factories: Factories) -> None: "spectate_link": game.spectate_link, "confirmed": game.confirmed, "requires_confirmation": game.requires_confirmation, + "password": game.password, } def test_game_show_links(self, factories: Factories) -> None: diff --git a/tests/services/test_games.py b/tests/services/test_games.py index 73083708..e83bfc4f 100644 --- a/tests/services/test_games.py +++ b/tests/services/test_games.py @@ -128,12 +128,13 @@ async def test_games_fully_seated(self, guild: Guild, channel: Channel) -> None: async def test_games_make_ready(self, game: Game) -> None: games = GamesService() await games.select(game.id) - await games.make_ready("http://link") + await games.make_ready("http://link", "whatever") DatabaseSession.expire_all() found = DatabaseSession.query(Game).get(game.id) assert found assert found.spelltable_link == "http://link" + assert found.password == "whatever" assert found.status == GameStatus.STARTED.value async def test_games_player_xids(self, game: Game) -> None: diff --git a/tests/test_bot.py b/tests/test_bot.py index 41f4e6d6..5e248730 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -27,8 +27,9 @@ @pytest.mark.asyncio class TestSpellBot(BaseMixin): async def test_create_create_game_link(self, bot: SpellBot) -> None: - link = await bot.create_game_link(MagicMock()) + link, password = await bot.create_game_link(MagicMock()) assert link is not None + assert password is None assert link.startswith("http://exmaple.com/game/") @pytest.mark.parametrize(