From 053a98010ed8bc89e3f4b9d3d4938ff34ea5d0a0 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:54:47 -0300 Subject: [PATCH 1/3] v3.0.7 --- pyproject.toml | 2 +- src/gw2/cogs/sessions.py | 99 ++++++++++++----- src/gw2/tools/gw2_utils.py | 92 ++++++++++------ tests/unit/gw2/cogs/test_sessions.py | 141 +++++++++++++++++++++---- tests/unit/gw2/tools/test_gw2_utils.py | 140 ++++++++++++++++++++---- uv.lock | 2 +- 6 files changed, 379 insertions(+), 97 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4b75d47..54e6e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "DiscordBot" -version = "3.0.6" +version = "3.0.7" description = "A simple Discord bot with OpenAI support and server administration tools" urls.Repository = "https://github.com/ddc/DiscordBot" urls.Homepage = "https://github.com/ddc/DiscordBot" diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 7c37fd2..0b6a04b 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -9,6 +9,7 @@ from src.gw2.constants import gw2_messages from src.gw2.constants.gw2_currencies import WALLET_DISPLAY_NAMES from src.gw2.tools import gw2_utils +from src.gw2.tools.gw2_client import Gw2Client from src.gw2.tools.gw2_cooldowns import GW2CoolDowns @@ -88,23 +89,35 @@ async def session(ctx): rs_start = rs_session[0]["start"] rs_end = rs_session[0]["end"] + is_live_snapshot = False + + is_playing = _is_user_playing_gw2(ctx) if rs_end is None: - # Check if the user is currently playing GW2 - is_playing = ( - 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() - ) if is_playing: - return await gw2_utils.send_msg(ctx, gw2_messages.SESSION_IN_PROGRESS) - # 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) - - progress_msg = await gw2_utils.send_progress_embed( - ctx, "Please wait, I'm fetching your session data... (this may take a moment)" - ) + # User is still playing - fetch live snapshot from API without saving to DB + progress_msg = await gw2_utils.send_progress_embed(ctx) + live_stats = await gw2_utils.get_user_stats(ctx.bot, api_key) + if not live_stats: + await progress_msg.delete() + return await gw2_utils.send_msg(ctx, gw2_messages.SESSION_IN_PROGRESS) + live_stats["date"] = bot_utils.convert_datetime_to_str_short(bot_utils.get_current_date_time()) + rs_end = live_stats + is_live_snapshot = True + else: + # End data missing and user stopped playing - finalize the session now + progress_msg = await gw2_utils.send_progress_embed(ctx) + await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) + rs_session = await gw2_session_dal.get_user_last_session(user_id) + if not rs_session or rs_session[0]["end"] is None: + await progress_msg.delete() + return await bot_utils.send_error_msg(ctx, gw2_messages.SESSION_BOT_STILL_UPDATING) + rs_start = rs_session[0]["start"] + rs_end = rs_session[0]["end"] + else: + progress_msg = await gw2_utils.send_progress_embed( + ctx, "Please wait, I'm fetching your session data... (this may take a moment)" + ) try: color = ctx.bot.settings["gw2"]["EmbedColor"] @@ -115,7 +128,7 @@ async def session(ctx): 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: + if not is_live_snapshot and 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" await progress_msg.delete() @@ -125,9 +138,10 @@ async def session(ctx): acc_name = rs_session[0]["acc_name"] session_date = rs_start["date"].split()[0] + title_suffix = " (Live)" if is_live_snapshot else "" embed = discord.Embed(color=color) embed.set_author( - name=f"{ctx.message.author.display_name}'s {gw2_messages.SESSION_TITLE} ({session_date})", + name=f"{ctx.message.author.display_name}'s {gw2_messages.SESSION_TITLE} ({session_date}){title_suffix}", icon_url=ctx.message.author.display_avatar.url, ) embed.add_field(name=gw2_messages.ACCOUNT_NAME, value=chat_formatting.inline(acc_name)) @@ -141,8 +155,11 @@ async def session(ctx): _add_gold_field(embed, rs_start, rs_end) # Deaths - gw2_session_chars_dal = Gw2SessionCharDeathsDal(ctx.bot.db_session, ctx.bot.log) - char_deaths = await gw2_session_chars_dal.get_char_deaths(user_id) + if is_live_snapshot: + char_deaths = await _build_live_char_deaths(ctx, api_key, user_id) + else: + gw2_session_chars_dal = Gw2SessionCharDeathsDal(ctx.bot.db_session, ctx.bot.log) + char_deaths = await gw2_session_chars_dal.get_char_deaths(user_id) if char_deaths: _add_deaths_field(embed, char_deaths) @@ -152,17 +169,15 @@ async def session(ctx): # All wallet currencies (except gold, handled above) _add_wallet_currency_fields(embed, rs_start, rs_end) + footer_text = f"{bot_utils.get_current_date_time_str_long()} UTC" + if is_live_snapshot: + footer_text += f"\n{gw2_messages.SESSION_USER_STILL_PLAYING}" embed.set_footer( icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None, - text=f"{bot_utils.get_current_date_time_str_long()} UTC", + text=footer_text, ) - 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() - ): + if not is_live_snapshot and is_playing: 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) @@ -176,6 +191,40 @@ async def session(ctx): return None +def _is_user_playing_gw2(ctx) -> bool: + """Check if the user is currently playing GW2 by checking all activities.""" + if isinstance(ctx.channel, discord.DMChannel): + return False + member = ctx.message.author + if not hasattr(member, "activities"): + return False + for activity in member.activities: + if activity.type is not discord.ActivityType.custom and "guild wars 2" in str(activity.name).lower(): + return True + return False + + +async def _build_live_char_deaths(ctx, api_key: str, user_id: int) -> list[dict] | None: + """Build char deaths for live snapshot by merging DB start deaths with current API data.""" + gw2_session_chars_dal = Gw2SessionCharDeathsDal(ctx.bot.db_session, ctx.bot.log) + db_char_deaths = await gw2_session_chars_dal.get_char_deaths(user_id) + if not db_char_deaths: + return None + + try: + gw2_api = Gw2Client(ctx.bot) + characters_data = await gw2_api.call_api("characters?ids=all", api_key) + except Exception: + return None + + api_deaths = {char["name"]: char["deaths"] for char in characters_data} + for row in db_char_deaths: + if row["name"] in api_deaths: + row["end"] = api_deaths[row["name"]] + + return db_char_deaths + + def _add_gold_field(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: """Add gold gained/lost field to embed.""" if "gold" not in rs_start or "gold" not in rs_end: diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index adb3b40..1b6ff32 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -30,7 +30,7 @@ def __init__(self): _gw2_settings = get_gw2_settings() _background_tasks: set[asyncio.Task] = set() -_processing_sessions: set[int] = set() +_processing_sessions: dict[int, str | None] = {} _achievement_cache: dict[int, dict] = {} @@ -307,13 +307,24 @@ async def check_gw2_game_activity(bot: Bot, before: discord.Member, after: disco before_activity = _get_non_custom_activity(before.activities) after_activity = _get_non_custom_activity(after.activities) - if _is_gw2_activity_detected(before_activity, after_activity): - bot.log.debug( - f"GW2 activity detected for {after.id}: " - f"before={before_activity.name if before_activity else None}, " - f"after={after_activity.name if after_activity else None}" - ) - await _handle_gw2_activity_change(bot, after, after_activity) + if not _is_gw2_activity_detected(before_activity, after_activity): + return + + before_is_gw2 = before_activity is not None and "guild wars 2" in str(before_activity.name).lower() + after_is_gw2 = after_activity is not None and "guild wars 2" in str(after_activity.name).lower() + + bot.log.debug( + f"GW2 activity detected for {after.id}: " + f"before={before_activity.name if before_activity else None}, " + f"after={after_activity.name if after_activity else None}" + ) + + if before_is_gw2 and after_is_gw2: + bot.log.debug(f"User {after.id} still playing GW2, no session change needed") + return + + action = "start" if after_is_gw2 else "end" + await _handle_gw2_activity_change(bot, after, action) def _get_non_custom_activity(activities) -> discord.Activity | None: @@ -334,39 +345,58 @@ def _is_gw2_activity_detected(before_activity, after_activity) -> bool: async def _handle_gw2_activity_change( bot: Bot, member: discord.Member, - after_activity, + action: str, ) -> None: - """Handle GW2 activity changes and manage session tracking.""" + """Handle GW2 activity changes and manage session tracking. + + Uses a per-user pending-action queue so that end events arriving while + a start is in progress are never silently dropped. + """ if member.id in _processing_sessions: - bot.log.debug(f"Session operation already in progress for user {member.id}, skipping duplicate") + _processing_sessions[member.id] = action + bot.log.debug( + f"Session operation in progress for user {member.id}, " + f"queuing '{action}' as pending" + ) return - _processing_sessions.add(member.id) + _processing_sessions[member.id] = None try: - gw2_configs = Gw2ConfigsDal(bot.db_session, bot.log) - server_configs = await gw2_configs.get_gw2_server_configs(member.guild.id) + while action is not None: + await _execute_session_action(bot, member, action) + # Check if a new action was queued while we were processing + action = _processing_sessions[member.id] + _processing_sessions[member.id] = None + if action is not None: + bot.log.debug(f"Processing pending '{action}' action for user {member.id}") + finally: + _processing_sessions.pop(member.id, None) - if not server_configs or not server_configs[0]["session"]: - bot.log.debug(f"Session tracking not enabled for guild {member.guild.id}, skipping") - return - gw2_key_dal = Gw2KeyDal(bot.db_session, bot.log) - api_key_result = await gw2_key_dal.get_api_key_by_user(member.id) +async def _execute_session_action(bot: Bot, member: discord.Member, action: str) -> None: + """Execute a single session action (start or end).""" + gw2_configs = Gw2ConfigsDal(bot.db_session, bot.log) + server_configs = await gw2_configs.get_gw2_server_configs(member.guild.id) - if not api_key_result: - bot.log.debug(f"No GW2 API key found for user {member.id}, skipping session") - return + if not server_configs or not server_configs[0]["session"]: + bot.log.debug(f"Session tracking not enabled for guild {member.guild.id}, skipping") + return - api_key = api_key_result[0]["key"] + gw2_key_dal = Gw2KeyDal(bot.db_session, bot.log) + api_key_result = await gw2_key_dal.get_api_key_by_user(member.id) - if after_activity is not None: - bot.log.debug(f"Starting GW2 session for user {member.id}") - await start_session(bot, member, api_key) - else: - bot.log.debug(f"Ending GW2 session for user {member.id}") - await end_session(bot, member, api_key) - finally: - _processing_sessions.discard(member.id) + if not api_key_result: + bot.log.debug(f"No GW2 API key found for user {member.id}, skipping session") + return + + api_key = api_key_result[0]["key"] + + if action == "start": + bot.log.debug(f"Starting GW2 session for user {member.id}") + await start_session(bot, member, api_key) + else: + bot.log.debug(f"Ending GW2 session for user {member.id}") + await end_session(bot, member, api_key) async def start_session(bot: Bot, member: discord.Member, api_key: str) -> None: diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 3952362..7282685 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -9,6 +9,8 @@ _add_gold_field, _add_wallet_currency_fields, _add_wvw_stats, + _build_live_char_deaths, + _is_user_playing_gw2, session, setup, ) @@ -110,7 +112,7 @@ def mock_ctx(self): ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/avatar.png" ctx.message.author.mention = "<@12345>" - ctx.message.author.activity = None + ctx.message.author.activities = () ctx.message.channel = MagicMock() ctx.message.channel.typing = MagicMock() ctx.message.channel.typing.return_value.__aenter__ = AsyncMock(return_value=None) @@ -288,36 +290,129 @@ async def test_session_no_session_found(self, mock_ctx, sample_api_key_data): assert len(mock_error.call_args[0]) == 2 @pytest.mark.asyncio - async def test_session_end_date_is_none(self, mock_ctx, sample_api_key_data): - """Test session command when session end is None and user not playing.""" + async def test_session_end_date_is_none_api_fails(self, mock_ctx, sample_api_key_data): + """Test session command when session end is None, user not playing, and API fails to finalize.""" session_data = [{"acc_name": "TestUser.1234", "start": {}, "end": None}] with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: + # end_session is called but fails, so re-fetch still returns None end mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) - with patch("src.gw2.cogs.sessions.gw2_utils.send_msg") as mock_send: - await session(mock_ctx) - mock_send.assert_called_once() - assert gw2_messages.SESSION_BOT_STILL_UPDATING in mock_send.call_args[0][1] + with patch("src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock): + with patch( + "src.gw2.cogs.sessions.gw2_utils.send_progress_embed", + new_callable=AsyncMock, + return_value=AsyncMock(), + ): + with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: + await session(mock_ctx) + mock_error.assert_called_once() + assert gw2_messages.SESSION_BOT_STILL_UPDATING in str(mock_error.call_args[0][1]) + + @pytest.mark.asyncio + async def test_session_end_date_is_none_auto_completes(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command auto-completes when end is None and user stopped playing.""" + start_data = {"date": "2024-01-15 10:00:00", "gold": 100000, "wvw_rank": 100} + end_data = {"date": "2024-01-15 12:30:00", "gold": 120000, "wvw_rank": 101} + session_no_end = [{"acc_name": "TestUser.1234", "start": start_data, "end": None}] + session_with_end = [{"acc_name": "TestUser.1234", "start": start_data, "end": end_data}] + + with ( + patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal, + patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs, + patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal, + patch("src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock) as mock_end, + patch( + "src.gw2.cogs.sessions.gw2_utils.send_progress_embed", + new_callable=AsyncMock, + return_value=AsyncMock(), + ), + 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.Gw2SessionCharDeathsDal") as mock_chars_dal, + 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) + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) + # First call returns no end, second call (after end_session) returns with end + mock_sessions_dal.return_value.get_user_last_session = AsyncMock( + side_effect=[session_no_end, session_with_end] + ) + mock_chars_dal.return_value.get_char_deaths = AsyncMock(return_value=None) + + await session(mock_ctx) + + mock_end.assert_called_once() + mock_send.assert_called_once() @pytest.mark.asyncio - async def test_session_end_date_is_none_while_playing(self, mock_ctx, sample_api_key_data): - """Test session command when end is None and user is currently playing GW2.""" + async def test_session_end_date_is_none_while_playing_api_fails(self, mock_ctx, sample_api_key_data): + """Test session command when end is None, playing, but API fails - shows SESSION_IN_PROGRESS.""" session_data = [{"acc_name": "TestUser.1234", "start": {}, "end": None}] - mock_ctx.message.author.activity = MagicMock() - mock_ctx.message.author.activity.name = "Guild Wars 2" + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + mock_ctx.message.author.activities = (gw2_activity,) with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) - with patch("src.gw2.cogs.sessions.gw2_utils.send_msg") as mock_send: - await session(mock_ctx) - mock_send.assert_called_once() - assert gw2_messages.SESSION_IN_PROGRESS in mock_send.call_args[0][1] + with patch("src.gw2.cogs.sessions.gw2_utils.get_user_stats", new_callable=AsyncMock, return_value=None): + with patch( + "src.gw2.cogs.sessions.gw2_utils.send_progress_embed", + new_callable=AsyncMock, + return_value=AsyncMock(), + ): + with patch("src.gw2.cogs.sessions.gw2_utils.send_msg") as mock_send: + await session(mock_ctx) + mock_send.assert_called_once() + assert gw2_messages.SESSION_IN_PROGRESS in mock_send.call_args[0][1] + + @pytest.mark.asyncio + async def test_session_live_snapshot_while_playing(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command shows live snapshot when user is playing and end is None.""" + start_data = {"date": "2024-01-15 10:00:00", "gold": 100000, "wvw_rank": 100} + session_data = [{"acc_name": "TestUser.1234", "start": start_data, "end": None}] + live_stats = {"acc_name": "TestUser.1234", "gold": 120000, "wvw_rank": 101} + + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + mock_ctx.message.author.activities = (gw2_activity,) + + with ( + patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal, + patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs, + patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal, + patch("src.gw2.cogs.sessions.gw2_utils.get_user_stats", new_callable=AsyncMock, return_value=live_stats), + patch("src.gw2.cogs.sessions.bot_utils.convert_datetime_to_str_short", return_value="2024-01-15 12:30:00"), + 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.Gw2SessionCharDeathsDal"), + patch("src.gw2.cogs.sessions._build_live_char_deaths", new_callable=AsyncMock, return_value=None), + 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}`"), + patch( + "src.gw2.cogs.sessions.gw2_utils.send_progress_embed", + new_callable=AsyncMock, + return_value=AsyncMock(), + ), + ): + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) + + await session(mock_ctx) + + mock_send.assert_called_once() + embed = mock_send.call_args[0][1] + assert "(Live)" in embed.author.name + assert gw2_messages.SESSION_USER_STILL_PLAYING in embed.footer.text @pytest.mark.asyncio async def test_session_time_passed_less_than_one_minute(self, mock_ctx, sample_api_key_data): @@ -720,10 +815,12 @@ async def test_session_wvw_stat_not_shown_when_missing_from_start( @pytest.mark.asyncio async def test_session_still_playing_gw2(self, mock_ctx, sample_api_key_data, sample_time_passed): - """Test session command when user is still playing GW2.""" + """Test session command when user is still playing GW2 (completed session exists).""" session_data = _make_session_data() - mock_ctx.message.author.activity = MagicMock() - mock_ctx.message.author.activity.name = "Guild Wars 2" + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + mock_ctx.message.author.activities = (gw2_activity,) mock_ctx.channel = MagicMock(spec=discord.TextChannel) runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) @@ -738,7 +835,7 @@ async def test_session_still_playing_gw2(self, mock_ctx, sample_api_key_data, sa async def test_session_not_playing_gw2_no_activity(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when user has no activity (not playing).""" session_data = _make_session_data() - mock_ctx.message.author.activity = None + mock_ctx.message.author.activities = () runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) async with runner.run() as r: @@ -753,8 +850,10 @@ async def test_session_dm_channel_no_still_playing(self, mock_ctx, sample_api_ke """Test session command in DM channel does not trigger still playing message.""" session_data = _make_session_data() mock_ctx.channel = MagicMock(spec=discord.DMChannel) - mock_ctx.message.author.activity = MagicMock() - mock_ctx.message.author.activity.name = "Guild Wars 2" + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + mock_ctx.message.author.activities = (gw2_activity,) runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) async with runner.run() as r: diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index d047c8b..5b572c4 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -9,11 +9,13 @@ TimeObject, _calculate_earned_points, _create_initial_user_stats, + _execute_session_action, _fetch_achievement_data_in_batches, _get_non_custom_activity, _get_wvw_rank_prefix, _handle_gw2_activity_change, _is_gw2_activity_detected, + _processing_sessions, _retry_session_later, _update_achievement_stats, _update_wallet_stats, @@ -41,6 +43,7 @@ start_session, update_end_char_deaths, ) +import asyncio from unittest.mock import AsyncMock, MagicMock, patch @@ -631,7 +634,7 @@ def mock_bot(self): @pytest.mark.asyncio async def test_gw2_activity_detected_triggers_handler(self, mock_bot): - """Test that GW2 activity detected triggers handler (lines 250-254).""" + """Test that GW2 activity detected triggers handler.""" before = MagicMock() after = MagicMock() @@ -645,7 +648,42 @@ async def test_gw2_activity_detected_triggers_handler(self, mock_bot): with patch("src.gw2.tools.gw2_utils._handle_gw2_activity_change") as mock_handle: mock_handle.return_value = None await check_gw2_game_activity(mock_bot, before, after) - mock_handle.assert_called_once_with(mock_bot, after, gw2_activity) + mock_handle.assert_called_once_with(mock_bot, after, "start") + + @pytest.mark.asyncio + async def test_gw2_stopped_triggers_end(self, mock_bot): + """Test that stopping GW2 triggers end action.""" + before = MagicMock() + after = MagicMock() + + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + + before.activities = [gw2_activity] + after.activities = [] + + with patch("src.gw2.tools.gw2_utils._handle_gw2_activity_change") as mock_handle: + mock_handle.return_value = None + await check_gw2_game_activity(mock_bot, before, after) + mock_handle.assert_called_once_with(mock_bot, after, "end") + + @pytest.mark.asyncio + async def test_gw2_to_gw2_skipped(self, mock_bot): + """Test that GW2->GW2 transitions are skipped (no session change).""" + before = MagicMock() + after = MagicMock() + + gw2_activity = MagicMock() + gw2_activity.type = discord.ActivityType.playing + gw2_activity.name = "Guild Wars 2" + + before.activities = [gw2_activity] + after.activities = [gw2_activity] + + with patch("src.gw2.tools.gw2_utils._handle_gw2_activity_change") as mock_handle: + await check_gw2_game_activity(mock_bot, before, after) + mock_handle.assert_not_called() @pytest.mark.asyncio async def test_no_gw2_activity_does_nothing(self, mock_bot): @@ -704,13 +742,12 @@ def mock_member(self): @pytest.mark.asyncio async def test_no_server_configs_returns(self, mock_bot, mock_member): - """Test that no server configs returns early (line 281).""" + """Test that no server configs returns early.""" with patch("src.gw2.tools.gw2_utils.Gw2ConfigsDal") as mock_dal: mock_instance = mock_dal.return_value mock_instance.get_gw2_server_configs = AsyncMock(return_value=None) - after_activity = MagicMock() - await _handle_gw2_activity_change(mock_bot, mock_member, after_activity) + await _handle_gw2_activity_change(mock_bot, mock_member, "start") # Should not proceed to Gw2KeyDal with patch("src.gw2.tools.gw2_utils.Gw2KeyDal") as mock_key_dal: @@ -718,17 +755,16 @@ async def test_no_server_configs_returns(self, mock_bot, mock_member): @pytest.mark.asyncio async def test_session_not_active_returns(self, mock_bot, mock_member): - """Test that inactive session returns early (line 281).""" + """Test that inactive session returns early.""" with patch("src.gw2.tools.gw2_utils.Gw2ConfigsDal") as mock_dal: mock_instance = mock_dal.return_value mock_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": False}]) - after_activity = MagicMock() - await _handle_gw2_activity_change(mock_bot, mock_member, after_activity) + await _handle_gw2_activity_change(mock_bot, mock_member, "start") @pytest.mark.asyncio async def test_no_api_key_returns(self, mock_bot, mock_member): - """Test that no API key returns early (lines 287-288).""" + """Test that no API key returns early.""" with patch("src.gw2.tools.gw2_utils.Gw2ConfigsDal") as mock_dal: mock_configs = mock_dal.return_value mock_configs.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) @@ -737,12 +773,11 @@ async def test_no_api_key_returns(self, mock_bot, mock_member): mock_key_instance = mock_key_dal.return_value mock_key_instance.get_api_key_by_user = AsyncMock(return_value=None) - after_activity = MagicMock() - await _handle_gw2_activity_change(mock_bot, mock_member, after_activity) + await _handle_gw2_activity_change(mock_bot, mock_member, "start") @pytest.mark.asyncio - async def test_after_activity_not_none_starts_session(self, mock_bot, mock_member): - """Test that non-None after_activity starts a session (lines 292-293).""" + async def test_start_action_starts_session(self, mock_bot, mock_member): + """Test that 'start' action starts a session.""" with patch("src.gw2.tools.gw2_utils.Gw2ConfigsDal") as mock_dal: mock_configs = mock_dal.return_value mock_configs.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) @@ -753,15 +788,14 @@ async def test_after_activity_not_none_starts_session(self, mock_bot, mock_membe with patch("src.gw2.tools.gw2_utils.start_session") as mock_start: mock_start.return_value = None - after_activity = MagicMock() # Not None - await _handle_gw2_activity_change(mock_bot, mock_member, after_activity) + await _handle_gw2_activity_change(mock_bot, mock_member, "start") mock_start.assert_called_once_with(mock_bot, mock_member, "test-api-key-123") @pytest.mark.asyncio - async def test_after_activity_none_ends_session(self, mock_bot, mock_member): - """Test that None after_activity ends a session (lines 294-295).""" + async def test_end_action_ends_session(self, mock_bot, mock_member): + """Test that 'end' action ends a session.""" with patch("src.gw2.tools.gw2_utils.Gw2ConfigsDal") as mock_dal: mock_configs = mock_dal.return_value mock_configs.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) @@ -773,10 +807,80 @@ async def test_after_activity_none_ends_session(self, mock_bot, mock_member): with patch("src.gw2.tools.gw2_utils.end_session") as mock_end: mock_end.return_value = None - await _handle_gw2_activity_change(mock_bot, mock_member, None) + await _handle_gw2_activity_change(mock_bot, mock_member, "end") mock_end.assert_called_once_with(mock_bot, mock_member, "test-api-key-123") + @pytest.mark.asyncio + async def test_pending_end_processed_after_start(self, mock_bot, mock_member): + """Test that an end event queued during a start is processed after start finishes.""" + with patch("src.gw2.tools.gw2_utils._execute_session_action") as mock_execute: + call_count = 0 + + async def side_effect(bot, member, action): + nonlocal call_count + call_count += 1 + if call_count == 1: + # Simulate end event arriving during start processing + _processing_sessions[member.id] = "end" + + mock_execute.side_effect = side_effect + + await _handle_gw2_activity_change(mock_bot, mock_member, "start") + + assert mock_execute.call_count == 2 + mock_execute.assert_any_call(mock_bot, mock_member, "start") + mock_execute.assert_any_call(mock_bot, mock_member, "end") + assert mock_member.id not in _processing_sessions + + @pytest.mark.asyncio + async def test_duplicate_during_processing_queues_latest(self, mock_bot, mock_member): + """Test that only the latest pending action is kept when multiple arrive during processing.""" + with patch("src.gw2.tools.gw2_utils._execute_session_action") as mock_execute: + call_count = 0 + + async def side_effect(bot, member, action): + nonlocal call_count + call_count += 1 + if call_count == 1: + # Simulate: start queued, then end queued (end wins) + _processing_sessions[member.id] = "start" + _processing_sessions[member.id] = "end" + + mock_execute.side_effect = side_effect + + await _handle_gw2_activity_change(mock_bot, mock_member, "start") + + assert mock_execute.call_count == 2 + # Second call should be "end" (last queued action) + mock_execute.assert_any_call(mock_bot, mock_member, "end") + assert mock_member.id not in _processing_sessions + + @pytest.mark.asyncio + async def test_concurrent_call_queues_instead_of_dropping(self, mock_bot, mock_member): + """Test that a concurrent call queues the action instead of dropping it.""" + # Simulate a session already being processed + _processing_sessions[mock_member.id] = None + + try: + await _handle_gw2_activity_change(mock_bot, mock_member, "end") + + # The action should be queued, not dropped + assert _processing_sessions[mock_member.id] == "end" + finally: + _processing_sessions.pop(mock_member.id, None) + + @pytest.mark.asyncio + async def test_processing_sessions_cleaned_up_on_error(self, mock_bot, mock_member): + """Test that _processing_sessions is cleaned up even when an error occurs.""" + with patch("src.gw2.tools.gw2_utils._execute_session_action") as mock_execute: + mock_execute.side_effect = RuntimeError("test error") + + with pytest.raises(RuntimeError, match="test error"): + await _handle_gw2_activity_change(mock_bot, mock_member, "start") + + assert mock_member.id not in _processing_sessions + class TestStartSession: """Test cases for start_session function.""" diff --git a/uv.lock b/uv.lock index ca804e4..fc1591b 100644 --- a/uv.lock +++ b/uv.lock @@ -365,7 +365,7 @@ wheels = [ [[package]] name = "discordbot" -version = "3.0.6" +version = "3.0.7" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 3308912e9a8e423a945ae0d04f136b4616071a78 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:59:46 -0300 Subject: [PATCH 2/3] v3.0.7 --- src/gw2/tools/gw2_utils.py | 5 +-- tests/unit/gw2/cogs/test_sessions.py | 6 ++-- tests/unit/gw2/tools/test_gw2_utils.py | 2 -- uv.lock | 42 +++++++++++++------------- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 1b6ff32..93c4cb6 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -354,10 +354,7 @@ async def _handle_gw2_activity_change( """ if member.id in _processing_sessions: _processing_sessions[member.id] = action - bot.log.debug( - f"Session operation in progress for user {member.id}, " - f"queuing '{action}' as pending" - ) + bot.log.debug(f"Session operation in progress for user {member.id}, queuing '{action}' as pending") return _processing_sessions[member.id] = None diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 7282685..fcb1830 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -9,8 +9,6 @@ _add_gold_field, _add_wallet_currency_fields, _add_wvw_stats, - _build_live_char_deaths, - _is_user_playing_gw2, session, setup, ) @@ -362,7 +360,9 @@ async def test_session_end_date_is_none_while_playing_api_fails(self, mock_ctx, mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) - with patch("src.gw2.cogs.sessions.gw2_utils.get_user_stats", new_callable=AsyncMock, return_value=None): + with patch( + "src.gw2.cogs.sessions.gw2_utils.get_user_stats", new_callable=AsyncMock, return_value=None + ): with patch( "src.gw2.cogs.sessions.gw2_utils.send_progress_embed", new_callable=AsyncMock, diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index 5b572c4..e9520a0 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -9,7 +9,6 @@ TimeObject, _calculate_earned_points, _create_initial_user_stats, - _execute_session_action, _fetch_achievement_data_in_batches, _get_non_custom_activity, _get_wvw_rank_prefix, @@ -43,7 +42,6 @@ start_session, update_end_char_deaths, ) -import asyncio from unittest.mock import AsyncMock, MagicMock, patch diff --git a/uv.lock b/uv.lock index fc1591b..2930a98 100644 --- a/uv.lock +++ b/uv.lock @@ -1045,27 +1045,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] From 84ac3cb5196050fe0774c7cbcbab655a18116d56 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:21:23 -0300 Subject: [PATCH 3/3] v3.0.7 --- src/gw2/cogs/sessions.py | 5 ++++- src/gw2/constants/gw2_messages.py | 1 + src/gw2/tools/gw2_utils.py | 10 ++++++---- tests/unit/gw2/cogs/test_sessions.py | 9 +++++---- tests/unit/gw2/tools/test_gw2_utils.py | 11 ++++++----- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 0b6a04b..a1be57f 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -199,7 +199,10 @@ def _is_user_playing_gw2(ctx) -> bool: if not hasattr(member, "activities"): return False for activity in member.activities: - if activity.type is not discord.ActivityType.custom and "guild wars 2" in str(activity.name).lower(): + if ( + activity.type is not discord.ActivityType.custom + and gw2_messages.GW2_FULL_NAME.lower() in str(activity.name).lower() + ): return True return False diff --git a/src/gw2/constants/gw2_messages.py b/src/gw2/constants/gw2_messages.py index 9e88750..73f4b23 100644 --- a/src/gw2/constants/gw2_messages.py +++ b/src/gw2/constants/gw2_messages.py @@ -1,3 +1,4 @@ +GW2_FULL_NAME = "Guild Wars 2" ################################# # EVENT ON COMMAND ERROR ################################# diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 93c4cb6..42ef3fb 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -310,8 +310,10 @@ async def check_gw2_game_activity(bot: Bot, before: discord.Member, after: disco if not _is_gw2_activity_detected(before_activity, after_activity): return - before_is_gw2 = before_activity is not None and "guild wars 2" in str(before_activity.name).lower() - after_is_gw2 = after_activity is not None and "guild wars 2" in str(after_activity.name).lower() + before_is_gw2 = ( + before_activity is not None and gw2_messages.GW2_FULL_NAME.lower() in str(before_activity.name).lower() + ) + after_is_gw2 = after_activity is not None and gw2_messages.GW2_FULL_NAME.lower() in str(after_activity.name).lower() bot.log.debug( f"GW2 activity detected for {after.id}: " @@ -337,8 +339,8 @@ def _get_non_custom_activity(activities) -> discord.Activity | None: def _is_gw2_activity_detected(before_activity, after_activity) -> bool: """Check if Guild Wars 2 activity is detected in before or after states.""" - return (after_activity is not None and "guild wars 2" in str(after_activity.name).lower()) or ( - before_activity is not None and "guild wars 2" in str(before_activity.name).lower() + return (after_activity is not None and gw2_messages.GW2_FULL_NAME.lower() in str(after_activity.name).lower()) or ( + before_activity is not None and gw2_messages.GW2_FULL_NAME.lower() in str(before_activity.name).lower() ) diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index fcb1830..bcf83dd 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -14,6 +14,7 @@ ) from src.gw2.constants import gw2_messages from src.gw2.constants.gw2_currencies import WALLET_DISPLAY_NAMES +from src.gw2.constants.gw2_messages import GW2_FULL_NAME from unittest.mock import AsyncMock, MagicMock, patch @@ -352,7 +353,7 @@ async def test_session_end_date_is_none_while_playing_api_fails(self, mock_ctx, session_data = [{"acc_name": "TestUser.1234", "start": {}, "end": None}] gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME mock_ctx.message.author.activities = (gw2_activity,) with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) @@ -382,7 +383,7 @@ async def test_session_live_snapshot_while_playing(self, mock_ctx, sample_api_ke gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME mock_ctx.message.author.activities = (gw2_activity,) with ( @@ -819,7 +820,7 @@ async def test_session_still_playing_gw2(self, mock_ctx, sample_api_key_data, sa session_data = _make_session_data() gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME mock_ctx.message.author.activities = (gw2_activity,) mock_ctx.channel = MagicMock(spec=discord.TextChannel) @@ -852,7 +853,7 @@ async def test_session_dm_channel_no_still_playing(self, mock_ctx, sample_api_ke mock_ctx.channel = MagicMock(spec=discord.DMChannel) gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME mock_ctx.message.author.activities = (gw2_activity,) runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index e9520a0..d6f633d 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -4,6 +4,7 @@ import pytest from datetime import datetime, timedelta from src.gw2.constants.gw2_currencies import WALLET_DISPLAY_NAMES, WALLET_MAPPING +from src.gw2.constants.gw2_messages import GW2_FULL_NAME from src.gw2.tools.gw2_exceptions import APIConnectionError from src.gw2.tools.gw2_utils import ( TimeObject, @@ -638,7 +639,7 @@ async def test_gw2_activity_detected_triggers_handler(self, mock_bot): gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME before.activities = [] after.activities = [gw2_activity] @@ -656,7 +657,7 @@ async def test_gw2_stopped_triggers_end(self, mock_bot): gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME before.activities = [gw2_activity] after.activities = [] @@ -674,7 +675,7 @@ async def test_gw2_to_gw2_skipped(self, mock_bot): gw2_activity = MagicMock() gw2_activity.type = discord.ActivityType.playing - gw2_activity.name = "Guild Wars 2" + gw2_activity.name = GW2_FULL_NAME before.activities = [gw2_activity] after.activities = [gw2_activity] @@ -708,7 +709,7 @@ async def test_custom_activity_ignored(self, mock_bot): custom_activity = MagicMock() custom_activity.type = discord.ActivityType.custom - custom_activity.name = "Guild Wars 2" + custom_activity.name = GW2_FULL_NAME before.activities = [custom_activity] after.activities = [custom_activity] @@ -1824,7 +1825,7 @@ def test_get_non_custom_activity_empty(self): def test_is_gw2_activity_detected_after(self): """Test _is_gw2_activity_detected with GW2 in after activity.""" mock_activity = MagicMock() - mock_activity.name = "Guild Wars 2" + mock_activity.name = GW2_FULL_NAME result = _is_gw2_activity_detected(None, mock_activity) assert result is True