diff --git a/src/bot/constants/messages.py b/src/bot/constants/messages.py index ae27006..fa80795 100644 --- a/src/bot/constants/messages.py +++ b/src/bot/constants/messages.py @@ -174,6 +174,7 @@ class BotUtils: ) MESSAGE_REMOVED_FOR_PRIVACY = "Your message was removed for privacy." DELETE_MESSAGE_NO_PERMISSION = "Bot does not have permission to delete messages." + SEND_MESSAGE_FAILED = "An error occurred while sending the response. Please try again later." class DiceRolls: @@ -338,6 +339,7 @@ class Owner: DISABLED_DM = BotUtils.DISABLED_DM MESSAGE_REMOVED_FOR_PRIVACY = BotUtils.MESSAGE_REMOVED_FOR_PRIVACY DELETE_MESSAGE_NO_PERMISSION = BotUtils.DELETE_MESSAGE_NO_PERMISSION +SEND_MESSAGE_FAILED = BotUtils.SEND_MESSAGE_FAILED # Dice Rolls DICE_SIZE_NOT_VALID = DiceRolls.SIZE_NOT_VALID diff --git a/src/bot/tools/bot_utils.py b/src/bot/tools/bot_utils.py index 143ae05..fc8737e 100644 --- a/src/bot/tools/bot_utils.py +++ b/src/bot/tools/bot_utils.py @@ -154,11 +154,98 @@ async def send_embed(ctx, embed, dm=False): )) except (discord.Forbidden, discord.HTTPException): pass # Can't send to channel either, nothing we can do - # If channel send failed, the error is already logged above + else: + # Channel send failed — notify the user with a simple embed + try: + await ctx.send(embed=discord.Embed( + description=chat_formatting.error(messages.SEND_MESSAGE_FAILED), + color=discord.Color.red(), + )) + except (discord.Forbidden, discord.HTTPException): + pass # Can't send anything, nothing we can do except Exception as e: ctx.bot.log.error(f"Unexpected error sending message: {e}") +class EmbedPaginatorView(discord.ui.View): + """Interactive pagination view for embed pages with Previous/Next buttons.""" + + def __init__(self, pages: list[discord.Embed], author_id: int): + super().__init__(timeout=None) + self.pages = pages + self.current_page = 0 + self.author_id = author_id + self.message: discord.Message | None = None + self._update_buttons() + + def _update_buttons(self): + self.previous_button.disabled = self.current_page == 0 + self.page_indicator.label = f"{self.current_page + 1}/{len(self.pages)}" + self.next_button.disabled = self.current_page == len(self.pages) - 1 + + @discord.ui.button(label="\u25c0", style=discord.ButtonStyle.secondary) + async def previous_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.author_id: + return await interaction.response.send_message( + "Only the command invoker can use these buttons.", ephemeral=True + ) + self.current_page -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) + + @discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True) + async def page_indicator(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + + @discord.ui.button(label="\u25b6", style=discord.ButtonStyle.secondary) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.author_id: + return await interaction.response.send_message( + "Only the command invoker can use these buttons.", ephemeral=True + ) + self.current_page += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) + + +async def send_paginated_embed(ctx, embed: discord.Embed, max_fields: int = 25) -> None: + """Send an embed with pagination if it exceeds max_fields. + + Splits the embed's fields across multiple pages with navigation buttons. + Non-field properties (color, author, description, thumbnail) are preserved on each page. + """ + if len(embed.fields) <= max_fields: + await send_embed(ctx, embed) + return + + color = embed.color or ctx.bot.settings["bot"]["EmbedColor"] + total_fields = len(embed.fields) + pages = [] + + for i in range(0, total_fields, max_fields): + page_embed = discord.Embed(color=color, description=embed.description) + if embed.author: + page_embed.set_author(name=embed.author.name, icon_url=embed.author.icon_url) + if embed.thumbnail: + page_embed.set_thumbnail(url=embed.thumbnail.url) + + for field in embed.fields[i : i + max_fields]: + page_embed.add_field(name=field.name, value=field.value, inline=field.inline) + + page_number = (i // max_fields) + 1 + total_pages = (total_fields + max_fields - 1) // max_fields + page_embed.set_footer(text=f"Page {page_number}/{total_pages}") + pages.append(page_embed) + + if len(pages) == 1: + await send_embed(ctx, pages[0]) + return + + view = EmbedPaginatorView(pages, ctx.message.author.id) + msg = await ctx.send(embed=pages[0], view=view) + view.message = msg + + async def delete_message(ctx, warning=False): if not is_private_message(ctx): color = None diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index e1b6399..e9dd8c0 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -102,67 +102,66 @@ async def session(ctx): # Game stopped but end data not saved yet — bot may still be updating return await gw2_utils.send_msg(ctx, gw2_messages.SESSION_BOT_STILL_UPDATING) - await ctx.message.channel.typing() - color = ctx.bot.settings["gw2"]["EmbedColor"] - start_time = bot_utils.convert_str_to_datetime_short(rs_start["date"]) - end_time = bot_utils.convert_str_to_datetime_short(rs_end["date"]) - - time_passed = gw2_utils.get_time_passed(start_time, end_time) - player_wait_minutes = 1 - if time_passed.hours == 0 and time_passed.minutes < player_wait_minutes: - wait_time = str(player_wait_minutes - time_passed.minutes) - m = "minute" if wait_time == "1" else "minutes" - return await gw2_utils.send_msg( - ctx, f"{gw2_messages.SESSION_BOT_STILL_UPDATING}\n {gw2_messages.WAITING_TIME}: `{wait_time} {m}`" + async with ctx.message.channel.typing(): + color = ctx.bot.settings["gw2"]["EmbedColor"] + start_time = bot_utils.convert_str_to_datetime_short(rs_start["date"]) + end_time = bot_utils.convert_str_to_datetime_short(rs_end["date"]) + + time_passed = gw2_utils.get_time_passed(start_time, end_time) + player_wait_minutes = 1 + if time_passed.hours == 0 and time_passed.minutes < player_wait_minutes: + wait_time = str(player_wait_minutes - time_passed.minutes) + m = "minute" if wait_time == "1" else "minutes" + return await gw2_utils.send_msg( + ctx, f"{gw2_messages.SESSION_BOT_STILL_UPDATING}\n {gw2_messages.WAITING_TIME}: `{wait_time} {m}`" + ) + + acc_name = rs_session[0]["acc_name"] + embed = discord.Embed(color=color) + embed.set_author( + name=f"{ctx.message.author.display_name}'s {gw2_messages.SESSION_TITLE} ({rs_start['date'].split()[0]})", + icon_url=ctx.message.author.display_avatar.url, ) + embed.add_field(name=gw2_messages.ACCOUNT_NAME, value=chat_formatting.inline(acc_name)) + embed.add_field(name=gw2_messages.SERVER, value=chat_formatting.inline(gw2_server)) + + # Play time from API age (actual in-game time) + start_age = rs_start.get("age", 0) + end_age = rs_end.get("age", 0) + play_time_seconds = end_age - start_age + if play_time_seconds > 0: + play_time_str = gw2_utils.format_seconds_to_time(play_time_seconds) + else: + play_time_str = str(time_passed.timedelta) + embed.add_field(name=gw2_messages.PLAY_TIME, value=chat_formatting.inline(play_time_str)) + + # Gold (special formatting) + _add_gold_field(embed, rs_start, rs_end) + + # Deaths + gw2_session_chars_dal = Gw2SessionCharsDal(ctx.bot.db_session, ctx.bot.log) + rs_chars_start = await gw2_session_chars_dal.get_all_start_characters(user_id) + if rs_chars_start: + rs_chars_end = await gw2_session_chars_dal.get_all_end_characters(user_id) + _add_deaths_field(embed, rs_chars_start, rs_chars_end) + + # WvW achievement-based stats + _add_wvw_stats(embed, rs_start, rs_end) + + # All wallet currencies (except gold, handled above) + _add_wallet_currency_fields(embed, rs_start, rs_end) + + if ( + not (isinstance(ctx.channel, discord.DMChannel)) + and hasattr(ctx.message.author, "activity") + and ctx.message.author.activity is not None + and "guild wars 2" in str(ctx.message.author.activity.name).lower() + ): + still_playing_msg = f"{ctx.message.author.mention}\n {gw2_messages.SESSION_USER_STILL_PLAYING}" + await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) + await ctx.send(still_playing_msg) - acc_name = rs_session[0]["acc_name"] - embed = discord.Embed(color=color) - embed.set_author( - name=f"{ctx.message.author.display_name}'s {gw2_messages.SESSION_TITLE} ({rs_start['date'].split()[0]})", - icon_url=ctx.message.author.display_avatar.url, - ) - embed.add_field(name=gw2_messages.ACCOUNT_NAME, value=chat_formatting.inline(acc_name)) - embed.add_field(name=gw2_messages.SERVER, value=chat_formatting.inline(gw2_server)) - - # Play time from API age (actual in-game time) - start_age = rs_start.get("age", 0) - end_age = rs_end.get("age", 0) - play_time_seconds = end_age - start_age - if play_time_seconds > 0: - play_time_str = gw2_utils.format_seconds_to_time(play_time_seconds) - else: - play_time_str = str(time_passed.timedelta) - embed.add_field(name=gw2_messages.PLAY_TIME, value=chat_formatting.inline(play_time_str)) - - # Gold (special formatting) - _add_gold_field(embed, rs_start, rs_end) - - # Deaths - gw2_session_chars_dal = Gw2SessionCharsDal(ctx.bot.db_session, ctx.bot.log) - rs_chars_start = await gw2_session_chars_dal.get_all_start_characters(user_id) - if rs_chars_start: - rs_chars_end = await gw2_session_chars_dal.get_all_end_characters(user_id) - _add_deaths_field(embed, rs_chars_start, rs_chars_end) - - # WvW achievement-based stats - _add_wvw_stats(embed, rs_start, rs_end) - - # All wallet currencies (except gold, handled above) - _add_wallet_currency_fields(embed, rs_start, rs_end) - - if ( - not (isinstance(ctx.channel, discord.DMChannel)) - and hasattr(ctx.message.author, "activity") - and ctx.message.author.activity is not None - and "guild wars 2" in str(ctx.message.author.activity.name).lower() - ): - still_playing_msg = f"{ctx.message.author.mention}\n {gw2_messages.SESSION_USER_STILL_PLAYING}" - await ctx.message.channel.typing() - await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) - await ctx.send(still_playing_msg) - - await bot_utils.send_embed(ctx, embed) + await bot_utils.send_paginated_embed(ctx, embed) return None @@ -232,7 +231,14 @@ def _add_wvw_stats(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: - """Add wallet currency fields to embed (all except gold, which has special formatting).""" + """Add wallet currency fields to embed (all except gold, which has special formatting). + + Currencies are grouped into "Gained Currencies" and "Lost Currencies" fields + to avoid exceeding Discord's 25-field embed limit. + """ + gained_lines = [] + lost_lines = [] + for stat_key, display_name in WALLET_DISPLAY_NAMES.items(): if stat_key == "gold": continue @@ -242,9 +248,22 @@ def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: di if start_val != end_val: diff = end_val - start_val if diff > 0: - embed.add_field(name=f"Gained {display_name}", value=chat_formatting.inline(f"+{diff}")) + gained_lines.append(f"+{diff} {display_name}") else: - embed.add_field(name=f"Lost {display_name}", value=chat_formatting.inline(str(diff))) + lost_lines.append(f"{diff} {display_name}") + + if gained_lines: + embed.add_field( + name="Gained Currencies", + value=chat_formatting.inline("\n".join(gained_lines)), + inline=False, + ) + if lost_lines: + embed.add_field( + name="Lost Currencies", + value=chat_formatting.inline("\n".join(lost_lines)), + inline=False, + ) async def setup(bot): diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 43e8c03..ca05825 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -102,7 +102,9 @@ def mock_ctx(self): ctx.message.author.mention = "<@12345>" ctx.message.author.activity = None ctx.message.channel = MagicMock() - ctx.message.channel.typing = AsyncMock() + ctx.message.channel.typing = MagicMock() + ctx.message.channel.typing.return_value.__aenter__ = AsyncMock(return_value=None) + ctx.message.channel.typing.return_value.__aexit__ = AsyncMock(return_value=False) ctx.prefix = "!" ctx.guild = MagicMock() ctx.guild.id = 99999 @@ -179,7 +181,7 @@ async def run(self_runner): patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short", side_effect=lambda x: x), patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed", return_value=sample_time_passed), patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal_class, - patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send, + patch("src.gw2.cogs.sessions.bot_utils.send_paginated_embed") as mock_send, patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"), ): mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) @@ -508,8 +510,9 @@ async def test_session_karma_gained(self, mock_ctx, sample_api_key_data, sample_ async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - karma_field = next((f for f in embed.fields if f.name == "Gained Karma"), None) - assert karma_field is not None + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) + assert field is not None + assert "Karma" in field.value @pytest.mark.asyncio async def test_session_karma_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -519,8 +522,9 @@ async def test_session_karma_lost(self, mock_ctx, sample_api_key_data, sample_ti async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - karma_field = next((f for f in embed.fields if f.name == "Lost Karma"), None) - assert karma_field is not None + field = next((f for f in embed.fields if f.name == "Lost Currencies"), None) + assert field is not None + assert "Karma" in field.value @pytest.mark.asyncio async def test_session_laurels_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -530,8 +534,9 @@ async def test_session_laurels_gained(self, mock_ctx, sample_api_key_data, sampl async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Laurels"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Laurels" in field.value @pytest.mark.asyncio async def test_session_laurels_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -541,8 +546,9 @@ async def test_session_laurels_lost(self, mock_ctx, sample_api_key_data, sample_ async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Lost Laurels"), None) + field = next((f for f in embed.fields if f.name == "Lost Currencies"), None) assert field is not None + assert "Laurels" in field.value @pytest.mark.asyncio async def test_session_wvw_tickets_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -552,8 +558,9 @@ async def test_session_wvw_tickets_gained(self, mock_ctx, sample_api_key_data, s async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained WvW Skirmish Tickets"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "WvW Skirmish Tickets" in field.value @pytest.mark.asyncio async def test_session_wvw_tickets_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -563,8 +570,9 @@ async def test_session_wvw_tickets_lost(self, mock_ctx, sample_api_key_data, sam async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Lost WvW Skirmish Tickets"), None) + field = next((f for f in embed.fields if f.name == "Lost Currencies"), None) assert field is not None + assert "WvW Skirmish Tickets" in field.value @pytest.mark.asyncio async def test_session_proof_heroics_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -574,8 +582,9 @@ async def test_session_proof_heroics_gained(self, mock_ctx, sample_api_key_data, async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Proof of Heroics"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Proof of Heroics" in field.value @pytest.mark.asyncio async def test_session_badges_honor_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -585,8 +594,9 @@ async def test_session_badges_honor_gained(self, mock_ctx, sample_api_key_data, async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Badges of Honor"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Badges of Honor" in field.value @pytest.mark.asyncio async def test_session_guild_commendations_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): @@ -596,8 +606,9 @@ async def test_session_guild_commendations_gained(self, mock_ctx, sample_api_key async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Guild Commendations"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Guild Commendations" in field.value # === New currency tests === @@ -609,8 +620,9 @@ async def test_session_spirit_shards_gained(self, mock_ctx, sample_api_key_data, async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Spirit Shards"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Spirit Shards" in field.value assert "+10" in field.value @pytest.mark.asyncio @@ -621,8 +633,9 @@ async def test_session_volatile_magic_gained(self, mock_ctx, sample_api_key_data async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Volatile Magic"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Volatile Magic" in field.value assert "+200" in field.value @pytest.mark.asyncio @@ -633,8 +646,9 @@ async def test_session_unbound_magic_gained(self, mock_ctx, sample_api_key_data, async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Unbound Magic"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Unbound Magic" in field.value assert "+200" in field.value @pytest.mark.asyncio @@ -645,8 +659,9 @@ async def test_session_transmutation_charges_gained(self, mock_ctx, sample_api_k async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Gained Transmutation Charges"), None) + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) assert field is not None + assert "Transmutation Charges" in field.value assert "+5" in field.value @pytest.mark.asyncio @@ -657,8 +672,9 @@ async def test_session_currency_lost(self, mock_ctx, sample_api_key_data, sample async with runner.run() as r: await session(mock_ctx) embed = r.mock_send.call_args[0][1] - field = next((f for f in embed.fields if f.name == "Lost Spirit Shards"), None) + field = next((f for f in embed.fields if f.name == "Lost Currencies"), None) assert field is not None + assert "Spirit Shards" in field.value assert "-10" in field.value @pytest.mark.asyncio @@ -880,9 +896,10 @@ def test_currency_gained(self): end = {"karma": 200, "laurels": 15} with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): _add_wallet_currency_fields(embed, start, end) - field_names = [f.name for f in embed.fields] - assert "Gained Karma" in field_names - assert "Gained Laurels" in field_names + field = next((f for f in embed.fields if f.name == "Gained Currencies"), None) + assert field is not None + assert "Karma" in field.value + assert "Laurels" in field.value def test_currency_lost(self): embed = discord.Embed() @@ -890,9 +907,10 @@ def test_currency_lost(self): end = {"karma": 100} with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): _add_wallet_currency_fields(embed, start, end) - field = next((f for f in embed.fields if f.name == "Lost Karma"), None) + field = next((f for f in embed.fields if f.name == "Lost Currencies"), None) assert field is not None assert "-100" in field.value + assert "Karma" in field.value def test_gold_is_skipped(self): embed = discord.Embed() @@ -908,6 +926,16 @@ def test_no_change(self): _add_wallet_currency_fields(embed, start, end) assert len(embed.fields) == 0 + def test_gained_and_lost_separate_fields(self): + embed = discord.Embed() + start = {"karma": 100, "laurels": 50} + end = {"karma": 200, "laurels": 30} + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_wallet_currency_fields(embed, start, end) + field_names = [f.name for f in embed.fields] + assert "Gained Currencies" in field_names + assert "Lost Currencies" in field_names + class TestSessionSetup: """Test cases for session cog setup."""