From 450a1b357ec5aff67acaa13306997582413ba922 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 10 May 2026 22:06:56 +0200 Subject: [PATCH] fix: route results through league channel --- README.md | 2 +- tests/test_admin_panel_fixtures.py | 101 +++++++++++++++--- typer_bot/commands/admin_commands.py | 31 +++++- .../commands/admin_panel/unified_actions.py | 26 ++++- 4 files changed, 140 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 981e338..cb0c0da 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Ask the repo owner for the invite link. The bot needs: After inviting TyperBot, run `/admin panel`. First-time setup requires Discord `Administrator` or `Manage Server` permission and stores: - admin role: who can use league admin actions -- league channel: where fixture announcements, threads, and reminders go +- league channel: where fixture announcements, threads, reminders, and public result/standings posts go After setup, members with the configured admin role use `/admin panel` to create fixtures, enter results, calculate scores, review late partial predictions, edit scoring rules, and start new seasons. diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py index 586b061..f39378b 100644 --- a/tests/test_admin_panel_fixtures.py +++ b/tests/test_admin_panel_fixtures.py @@ -913,9 +913,15 @@ async def test_unified_panel_calculate_scores_button_posts_results( ["2-1", "1-1", "0-2"], False, ) - channel = MagicMock(spec=discord.TextChannel) - channel.send = AsyncMock() - mock_interaction_admin.channel = channel + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.id = 999999 + command_channel.send = AsyncMock() + league_channel = MagicMock(spec=discord.TextChannel) + league_channel.id = 123456 + league_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) mock_interaction_admin.message = MagicMock() mock_interaction_admin.message.edit = AsyncMock() admin_cog._create_backup = AsyncMock() @@ -935,16 +941,19 @@ async def test_unified_panel_calculate_scores_button_posts_results( calculate_button = _get_button(view, "Calculate Scores") await calculate_button.callback(mock_interaction_admin) + admin_cog.bot.get_channel.assert_called_with(123456) + admin_cog.bot.fetch_channel.assert_awaited_once_with(123456) assert ( admin_cog.get_calculate_cooldown("111111", str(mock_interaction_admin.user.id)) is not None ) - channel.send.assert_awaited_once() + league_channel.send.assert_awaited_once() + command_channel.send.assert_not_awaited() assert ( - "Week 45 results calculated and posted" + "Week 45 results calculated and posted to the league channel" in mock_interaction_admin.response_sent[-1]["content"] ) - assert "User One" in channel.send.call_args.args[0] + assert "User One" in league_channel.send.call_args.args[0] assert view.selection.fixture_label == "Week 45 [CLOSED]" assert _has_button(view, "Scoring Rules") is False assert _has_button(view, "Calculate Scores") is False @@ -953,6 +962,56 @@ async def test_unified_panel_calculate_scores_button_posts_results( content=view.render_content(), view=view ) + @pytest.mark.asyncio + async def test_unified_panel_calculate_scores_button_rejects_unavailable_league_channel( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock( + side_effect=discord.InvalidData("unknown channel type") + ) + mock_interaction_admin.message = MagicMock() + mock_interaction_admin.message.edit = AsyncMock() + admin_cog._create_backup = AsyncMock() + + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + await view.load_fixture_options() + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + calculate_button = _get_button(view, "Calculate Scores") + await calculate_button.callback(mock_interaction_admin) + + command_channel.send.assert_not_awaited() + assert ( + "configured league channel is unavailable" + in mock_interaction_admin.response_sent[-1]["content"].lower() + ) + @pytest.mark.asyncio async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored( self, @@ -1112,9 +1171,14 @@ async def test_unified_panel_post_results_button_opens_confirmation( admin_cog, mock_interaction_admin, ): - channel = MagicMock(spec=discord.TextChannel) - channel.id = mock_interaction_admin.channel.id - mock_interaction_admin.channel = channel + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.id = 999999 + league_channel = MagicMock(spec=discord.TextChannel) + league_channel.id = 123456 + league_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) admin_cog.db.get_last_fixture_scores = AsyncMock( return_value={ "week_number": 1, @@ -1154,7 +1218,11 @@ async def test_unified_panel_post_results_button_opens_confirmation( post_button = _get_button(view, "Re-post Results") await post_button.callback(mock_interaction_admin) - assert isinstance(mock_interaction_admin.response_sent[-1]["view"], PostResultsConfirmView) + admin_cog.bot.get_channel.assert_called_with(123456) + admin_cog.bot.fetch_channel.assert_awaited_once_with(123456) + confirm_view = mock_interaction_admin.response_sent[-1]["view"] + assert isinstance(confirm_view, PostResultsConfirmView) + assert confirm_view.channel is league_channel @pytest.mark.asyncio async def test_unified_panel_post_results_only_previews_current_guild_scores( @@ -1164,7 +1232,9 @@ async def test_unified_panel_post_results_only_previews_current_guild_scores( ): channel = MagicMock(spec=discord.TextChannel) channel.id = mock_interaction_admin.channel.id + channel.send = AsyncMock() mock_interaction_admin.channel = channel + admin_cog.bot.get_channel.return_value = channel deadline = datetime.now(UTC) - timedelta(days=1) current_fixture_id = await admin_cog.db.create_fixture( "111111", 1, ["Team A - Team B"], deadline @@ -1307,13 +1377,17 @@ async def test_unified_panel_jump_to_week_rejects_duplicate_open_weeks( assert view.selection.fixture_id is None @pytest.mark.asyncio - async def test_unified_panel_post_results_button_rejects_non_text_channel( + async def test_unified_panel_post_results_button_rejects_unavailable_league_channel( self, admin_cog, mock_interaction_admin, ): admin_cog.db.get_last_fixture_scores = AsyncMock(return_value={"scores": []}) admin_cog.db.get_standings = AsyncMock(return_value=[]) + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock( + side_effect=discord.InvalidData("unknown channel type") + ) view = UnifiedAdminPanelView( admin_cog.db, @@ -1326,7 +1400,10 @@ async def test_unified_panel_post_results_button_rejects_non_text_channel( post_button = _get_button(view, "Re-post Results") await post_button.callback(mock_interaction_admin) - assert "text channels" in mock_interaction_admin.response_sent[-1]["content"] + assert ( + "configured league channel is unavailable" + in mock_interaction_admin.response_sent[-1]["content"].lower() + ) @pytest.mark.asyncio async def test_unified_panel_post_results_button_rejects_missing_scores( diff --git a/typer_bot/commands/admin_commands.py b/typer_bot/commands/admin_commands.py index feaef9c..011ff4c 100644 --- a/typer_bot/commands/admin_commands.py +++ b/typer_bot/commands/admin_commands.py @@ -344,10 +344,33 @@ async def _post_calculation_to_channel( interaction: discord.Interaction, score_result: FixtureScoreResult, ) -> None: - channel = interaction.channel - if not isinstance(channel, (discord.TextChannel, discord.Thread, discord.DMChannel)): + if interaction.guild_id is None: await interaction.response.send_message( - "Could not find channel to post in.", ephemeral=True + "Scores calculated but could not resolve this server.", ephemeral=True + ) + return + + config = await self.db.get_guild_config(str(interaction.guild_id)) + channel = None + if config is not None: + try: + channel_id = int(config["league_channel_id"]) + except (TypeError, ValueError): + channel_id = None + if channel_id is not None: + channel = self.bot.get_channel(channel_id) + if channel is None: + fetch_channel = getattr(self.bot, "fetch_channel", None) + if fetch_channel is not None: + try: + channel = await fetch_channel(channel_id) + except discord.DiscordException: + channel = None + + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message( + "Scores calculated but the configured league channel is unavailable.", + ephemeral=True, ) return @@ -365,7 +388,7 @@ async def _post_calculation_to_channel( try: await channel.send(message) await interaction.response.send_message( - f"Week {score_result.fixture['week_number']} results calculated and posted!", + f"Week {score_result.fixture['week_number']} results calculated and posted to the league channel!", ephemeral=True, ) except Exception as exc: diff --git a/typer_bot/commands/admin_panel/unified_actions.py b/typer_bot/commands/admin_panel/unified_actions.py index bb23352..809b32e 100644 --- a/typer_bot/commands/admin_panel/unified_actions.py +++ b/typer_bot/commands/admin_panel/unified_actions.py @@ -477,13 +477,33 @@ async def callback(self, interaction: discord.Interaction): "No completed fixtures found with scores!", ephemeral=True ) return - if not isinstance(interaction.channel, discord.TextChannel): + + config = await self.parent_view.db.get_guild_config(self.parent_view.guild_id) + channel = None + if config is not None and self.parent_view.bot is not None: + try: + channel_id = int(config["league_channel_id"]) + except (TypeError, ValueError): + channel_id = None + if channel_id is not None: + channel = self.parent_view.bot.get_channel(channel_id) + if channel is None: + fetch_channel = getattr(self.parent_view.bot, "fetch_channel", None) + if fetch_channel is not None: + try: + channel = await fetch_channel(channel_id) + except discord.DiscordException: + channel = None + + if not isinstance(channel, discord.TextChannel): await interaction.response.send_message( - "This action can only be used in text channels.", ephemeral=True + "Configured league channel is unavailable. Run `/admin panel` again to update setup.", + ephemeral=True, ) return + preview = format_standings(standings, fixture_data) - view = PostResultsConfirmView(fixture_data, standings, interaction.channel) + view = PostResultsConfirmView(fixture_data, standings, channel) await interaction.response.send_message( f"{preview}\n\nMention users in this post?", view=view,