Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/bot/cogs/events/on_command_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,9 @@ def build_command_invoke_error(context: ErrorContext) -> str:
for conditions, message in error_conditions.items():
if any(condition in context.error_msg for condition in conditions):
base_msg = message
break
else:
base_msg = f"{messages.COMMAND_ERROR}: `{context.command}`\n{context.error_msg}"
return f"{base_msg}\n{messages.HELP_COMMAND_MORE_INFO}: `{context.help_command}`"

return f"{base_msg}\n{messages.HELP_COMMAND_MORE_INFO}: `{context.help_command}`"
return f"{messages.COMMAND_INTERNAL_ERROR}: `{context.command}`\n{messages.CONTACT_ADMIN}"

@staticmethod
def build_forbidden_error(context: ErrorContext) -> str:
Expand Down
2 changes: 2 additions & 0 deletions src/bot/constants/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class CommandError:
NO_PERMISSION = "Bot does not have permission to execute this command"
INVALID_MESSAGE = "Invalid message."
INTERNAL_ERROR = "There was an internal error with command"
CONTACT_ADMIN = "Please contact the server administrator"
DM_CANNOT_EXECUTE = "Cannot execute action on a DM channel"
PRIVILEGE_LOW = "Your Privilege is too low."
DIRECT_MESSAGES_DISABLED = (
Expand Down Expand Up @@ -287,6 +288,7 @@ class Owner:
NO_PERMISSION_EXECUTE_COMMAND = CommandError.NO_PERMISSION
INVALID_MESSAGE = CommandError.INVALID_MESSAGE
COMMAND_INTERNAL_ERROR = CommandError.INTERNAL_ERROR
CONTACT_ADMIN = CommandError.CONTACT_ADMIN
DM_CANNOT_EXECUTE_COMMAND = CommandError.DM_CANNOT_EXECUTE
PRIVILEGE_LOW = CommandError.PRIVILEGE_LOW
DIRECT_MESSAGES_DISABLED = CommandError.DIRECT_MESSAGES_DISABLED
Expand Down
26 changes: 15 additions & 11 deletions src/bot/tools/bot_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async def send_embed(ctx, embed, dm=False):
icon_url=ctx.author.avatar.url if ctx.author.avatar else ctx.author.default_avatar.url,
)
await ctx.send(embed=notification_embed)
except (discord.Forbidden, discord.HTTPException):
except discord.Forbidden, discord.HTTPException:
# DM failed, fall back to sending in the channel
await ctx.send(embed=embed)
else:
Expand All @@ -148,20 +148,24 @@ async def send_embed(ctx, embed, dm=False):
if dm or is_private_message(ctx):
# Only show DM disabled message when we were actually trying to DM
try:
await ctx.send(embed=discord.Embed(
description=chat_formatting.error(messages.DISABLED_DM),
color=discord.Color.red(),
))
except (discord.Forbidden, discord.HTTPException):
await ctx.send(
embed=discord.Embed(
description=chat_formatting.error(messages.DISABLED_DM),
color=discord.Color.red(),
)
)
except discord.Forbidden, discord.HTTPException:
pass # Can't send to channel either, nothing we can do
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):
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}")
Expand Down
11 changes: 1 addition & 10 deletions src/bot/tools/custom_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class HelpPaginatorView(discord.ui.View):
"""Interactive pagination view for help pages with Previous/Next buttons."""

def __init__(self, pages: list[str], author_id: int):
super().__init__(timeout=300)
super().__init__(timeout=None)
self.pages = pages
self.current_page = 0
self.author_id = author_id
Expand Down Expand Up @@ -47,15 +47,6 @@ async def next_button(self, interaction: discord.Interaction, button: discord.ui
self._update_buttons()
await interaction.response.edit_message(content=self._format_page(), view=self)

async def on_timeout(self):
for item in self.children:
item.disabled = True
try:
if self.message:
await self.message.edit(view=self)
except discord.NotFound, discord.HTTPException:
pass


class CustomHelpCommand(commands.DefaultHelpCommand):
"""Custom help command that sends DM notifications to the channel."""
Expand Down
28 changes: 12 additions & 16 deletions src/gw2/cogs/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,15 @@ def _add_gold_field(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None:
formatted_gold = gw2_utils.format_gold(full_gold)
if diff > 0:
embed.add_field(
name="Gained Gold",
name="Gold",
value=chat_formatting.inline(f"+{formatted_gold}"),
inline=False,
)
elif diff < 0:
final_result = f"{formatted_gold}"
if formatted_gold[0] != "-":
final_result = f"-{formatted_gold}"
embed.add_field(name="Lost Gold", value=chat_formatting.inline(str(final_result)), inline=False)
embed.add_field(name="Gold", value=chat_formatting.inline(str(final_result)), inline=False)


def _add_deaths_field(embed: discord.Embed, rs_chars_start: list[dict], rs_chars_end: list[dict]) -> None:
Expand Down Expand Up @@ -234,7 +234,7 @@ def _add_deaths_field(embed: discord.Embed, rs_chars_start: list[dict], rs_chars
def _add_wvw_stats(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None:
"""Add WvW achievement-based stats to embed."""
wvw_fields = [
("wvw_rank", gw2_messages.GAINED_WVW_RANKS),
("wvw_rank", gw2_messages.WVW_RANKS),
("yaks", gw2_messages.YAKS_KILLED),
("yaks_scorted", gw2_messages.YAKS_SCORTED),
("players", gw2_messages.PLAYERS_KILLED),
Expand All @@ -251,7 +251,10 @@ def _add_wvw_stats(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None:
end_val = rs_end[stat_key]
if start_val != end_val:
diff = end_val - start_val
embed.add_field(name=field_name, value=chat_formatting.inline(str(diff)))
if stat_key == "wvw_rank":
embed.add_field(name=field_name, value=chat_formatting.inline(str(diff)))
else:
embed.add_field(name=field_name, value=chat_formatting.inline(f"+{diff}" if diff > 0 else str(diff)))


def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None:
Expand All @@ -269,18 +272,11 @@ def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: di
end_val = rs_end[stat_key]
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}"),
inline=True,
)
else:
embed.add_field(
name=f"Lost {display_name}",
value=chat_formatting.inline(str(diff)),
inline=True,
)
embed.add_field(
name=display_name,
value=chat_formatting.inline(f"+{diff}" if diff > 0 else str(diff)),
inline=True,
)


async def setup(bot):
Expand Down
51 changes: 1 addition & 50 deletions src/gw2/cogs/worlds.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,14 @@
import discord
from discord.ext import commands
from src.bot.tools import bot_utils, chat_formatting
from src.bot.tools.bot_utils import EmbedPaginatorView
from src.gw2.cogs.gw2 import GuildWars2
from src.gw2.constants import gw2_messages
from src.gw2.tools import gw2_utils
from src.gw2.tools.gw2_client import Gw2Client
from src.gw2.tools.gw2_cooldowns import GW2CoolDowns


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=300)
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 on_timeout(self):
for item in self.children:
item.disabled = True
try:
if self.message:
await self.message.edit(view=self)
except discord.NotFound, discord.HTTPException:
pass


class GW2Worlds(GuildWars2):
"""Guild Wars 2 commands for listing worlds and WvW tiers."""

Expand Down
2 changes: 1 addition & 1 deletion src/gw2/constants/gw2_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def session_not_active(prefix: str) -> str:
SERVER = "Server"
PLAY_TIME = "Play time"
TIMES_YOU_DIED = "Times you died"
GAINED_WVW_RANKS = "Gained WvW ranks"
WVW_RANKS = "WvW ranks"
YAKS_KILLED = "Yaks killed"
YAKS_SCORTED = "Yaks escorted"
PLAYERS_KILLED = "Players killed"
Expand Down
2 changes: 2 additions & 0 deletions src/gw2/tools/gw2_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None:
if session_id is None:
bot.log.warning(f"No active session found for user {member.id}, skipping end session chars")
return
gw2_session_chars_dal = Gw2SessionCharsDal(bot.db_session, bot.log)
await gw2_session_chars_dal.delete_end_characters(session_id)
await insert_session_char(bot, member, api_key, session_id, "end")


Expand Down
20 changes: 11 additions & 9 deletions tests/unit/bot/events/test_on_command_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,23 +429,23 @@ class TestBuildCommandInvokeErrorElse:
"""Test cases for build_command_invoke_error else branch (line 111)."""

def test_build_command_invoke_error_no_matching_condition(self, mock_ctx, mock_error):
"""Test build_command_invoke_error when no error conditions match (line 111)."""
"""Test build_command_invoke_error when no error conditions match shows internal error."""
context = ErrorContext(mock_ctx, mock_error)
context.error_msg = "Some completely unrelated error that matches nothing"

result = ErrorMessageBuilder.build_command_invoke_error(context)
assert f"{messages.COMMAND_ERROR}: `!testcommand`" in result
assert "Some completely unrelated error that matches nothing" in result
assert f"{messages.HELP_COMMAND_MORE_INFO}: `!help testcommand`" in result
assert f"{messages.COMMAND_INTERNAL_ERROR}: `!testcommand`" in result
assert messages.CONTACT_ADMIN in result
assert messages.HELP_COMMAND_MORE_INFO not in result

def test_build_command_invoke_error_attribute_error(self, mock_ctx, mock_error):
"""Test build_command_invoke_error with AttributeError in message."""
"""Test build_command_invoke_error with AttributeError shows internal error."""
context = ErrorContext(mock_ctx, mock_error)
context.error_msg = "AttributeError: 'NoneType' object has no attribute 'foo'"

result = ErrorMessageBuilder.build_command_invoke_error(context)
assert f"{messages.COMMAND_ERROR}: `!testcommand`" in result
assert "AttributeError" in result
assert f"{messages.COMMAND_INTERNAL_ERROR}: `!testcommand`" in result
assert messages.CONTACT_ADMIN in result

def test_build_command_invoke_error_missing_permissions(self, mock_ctx, mock_error):
"""Test build_command_invoke_error with Missing Permissions."""
Expand Down Expand Up @@ -583,7 +583,8 @@ async def test_on_command_error_command_invoke_error(self, mock_send_error, mock

mock_send_error.assert_called_once()
call_msg = mock_send_error.call_args[0][1]
assert messages.COMMAND_ERROR in call_msg
assert messages.COMMAND_INTERNAL_ERROR in call_msg
assert messages.CONTACT_ADMIN in call_msg

@pytest.mark.asyncio
@patch("src.bot.cogs.events.on_command_error.bot_utils.send_error_msg")
Expand Down Expand Up @@ -772,7 +773,8 @@ async def test_handle_command_invoke_error(self, mock_send_error, errors_cog, mo
mock_send_error.assert_called_once()
call_args = mock_send_error.call_args[0]
assert call_args[0] == mock_ctx
assert messages.COMMAND_ERROR in call_args[1]
assert messages.COMMAND_INTERNAL_ERROR in call_args[1]
assert messages.CONTACT_ADMIN in call_args[1]
assert call_args[2] is True


Expand Down
2 changes: 1 addition & 1 deletion tests/unit/bot/tools/test_bot_utils_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ class TestEmbedPaginatorView:
"""Test EmbedPaginatorView class (lines 174-208)."""

def _make_pages(self, count=3):
return [discord.Embed(title=f"Page {i+1}") for i in range(count)]
return [discord.Embed(title=f"Page {i + 1}") for i in range(count)]

@pytest.mark.asyncio
async def test_init(self):
Expand Down
29 changes: 3 additions & 26 deletions tests/unit/bot/tools/test_custom_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,33 +153,10 @@ async def test_page_indicator_defers(self):
interaction.response.defer.assert_called_once()

@pytest.mark.asyncio
async def test_on_timeout_disables_all_buttons(self):
"""Test on_timeout disables all children and edits message."""
async def test_timeout_is_none(self):
"""Test view timeout is None (buttons never expire)."""
view = HelpPaginatorView(["A", "B"], author_id=1)
view.message = AsyncMock()

await view.on_timeout()

for item in view.children:
assert item.disabled is True
view.message.edit.assert_called_once_with(view=view)

@pytest.mark.asyncio
async def test_on_timeout_no_message(self):
"""Test on_timeout with no message reference does not raise."""
view = HelpPaginatorView(["A", "B"], author_id=1)
view.message = None

await view.on_timeout()

for item in view.children:
assert item.disabled is True

@pytest.mark.asyncio
async def test_timeout_is_300(self):
"""Test view timeout is 300 seconds."""
view = HelpPaginatorView(["A", "B"], author_id=1)
assert view.timeout == 300
assert view.timeout is None

@pytest.mark.asyncio
async def test_author_id_stored(self):
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/database/test_dal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,13 @@ async def test_insert_session_char_empty_characters(self, mock_dal):
gw2_api.call_api.assert_not_called()
mock_dal.db_utils.insert.assert_not_called()

@pytest.mark.asyncio
async def test_delete_end_characters(self, mock_dal):
"""Test delete_end_characters executes a delete statement for end chars."""
mock_dal.db_utils.execute = AsyncMock()
await mock_dal.delete_end_characters(session_id=42)
mock_dal.db_utils.execute.assert_called_once()

@pytest.mark.asyncio
async def test_get_all_start_characters(self, mock_dal):
"""Test get_all_start_characters calls fetchall and returns results."""
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/gw2/cogs/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import discord
import pytest
from src.gw2.cogs.account import GW2Account, account
from src.gw2.constants import gw2_messages
from src.gw2.tools.gw2_exceptions import APIInvalidKey
from unittest.mock import AsyncMock, MagicMock, patch

Expand Down Expand Up @@ -110,7 +111,7 @@ async def test_account_command_no_api_key(self, mock_ctx):

mock_error.assert_called_once()
error_msg = mock_error.call_args[0][1]
assert "You dont have an API key registered" in error_msg
assert gw2_messages.NO_API_KEY in error_msg

@pytest.mark.asyncio
async def test_account_command_invalid_api_key(self, mock_ctx, sample_api_key_data):
Expand Down Expand Up @@ -153,7 +154,7 @@ async def test_account_command_insufficient_permissions(self, mock_ctx, sample_a

mock_error.assert_called_once()
error_msg = mock_error.call_args[0][1]
assert "Your API key doesnt have permission" in error_msg
assert gw2_messages.API_KEY_NO_PERMISSION in error_msg

@pytest.mark.asyncio
async def test_account_command_successful_basic(
Expand Down
Loading