diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 7828ca8..1578f79 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -102,7 +102,11 @@ 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) - async with ctx.message.channel.typing(): + 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"] # Use JSONB date fields for session duration @@ -114,6 +118,7 @@ async def session(ctx): 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" + await progress_msg.delete() return await gw2_utils.send_msg( ctx, f"{gw2_messages.SESSION_BOT_STILL_UPDATING}\n {gw2_messages.WAITING_TIME}: `{wait_time} {m}`" ) @@ -163,7 +168,12 @@ async def session(ctx): await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) await ctx.send(still_playing_msg) + await progress_msg.delete() await bot_utils.send_paginated_embed(ctx, embed) + except Exception as e: + await progress_msg.delete() + await bot_utils.send_error_msg(ctx, e) + return ctx.bot.log.error(ctx, e) return None diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index b2ec93e..7be4375 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -31,6 +31,7 @@ def __init__(self): _gw2_settings = get_gw2_settings() _background_tasks: set[asyncio.Task] = set() _processing_sessions: set[int] = set() +_achievement_cache: dict[int, dict] = {} class Gw2Servers(Enum): @@ -125,30 +126,36 @@ async def calculate_user_achiev_points(ctx: commands.Context, api_req_acc_achiev async def _fetch_achievement_data_in_batches(gw2_api: Gw2Client, user_achievements: list[dict]) -> list[dict]: - """Fetch achievement data from API in parallel batches of 200.""" - batch_size = 200 - sem = asyncio.Semaphore(5) - - async def _fetch_batch(batch): - async with sem: - achievement_ids = [str(ach["id"]) for ach in batch] - ids_string = ",".join(achievement_ids) - return await gw2_api.call_api(f"achievements?ids={ids_string}") - - batch_tasks = [] - for i in range(0, len(user_achievements), batch_size): - batch = user_achievements[i : i + batch_size] - batch_tasks.append(_fetch_batch(batch)) - - results = await asyncio.gather(*batch_tasks, return_exceptions=True) - - all_achievement_data = [] - for result in results: - if isinstance(result, Exception): - continue - all_achievement_data.extend(result) - - return all_achievement_data + """Fetch achievement data from API in parallel batches of 200, with in-memory cache. + + Achievement definitions (tiers, points) are static game data that rarely change, + so caching them avoids 18+ API calls on every `gw2 account` invocation. + """ + needed_ids = [ach["id"] for ach in user_achievements if ach["id"] not in _achievement_cache] + + if needed_ids: + batch_size = 200 + sem = asyncio.Semaphore(5) + + async def _fetch_batch(batch_ids): + async with sem: + ids_string = ",".join(str(aid) for aid in batch_ids) + return await gw2_api.call_api(f"achievements?ids={ids_string}") + + batch_tasks = [] + for i in range(0, len(needed_ids), batch_size): + batch = needed_ids[i : i + batch_size] + batch_tasks.append(_fetch_batch(batch)) + + results = await asyncio.gather(*batch_tasks, return_exceptions=True) + + for result in results: + if isinstance(result, Exception): + continue + for ach in result: + _achievement_cache[ach["id"]] = ach + + return [_achievement_cache[ach["id"]] for ach in user_achievements if ach["id"] in _achievement_cache] def _calculate_earned_points(user_achievements: list[dict], achievement_data: list[dict]) -> int: diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 8ad2fc7..42a760b 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -193,6 +193,11 @@ async def run(self_runner): patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal_class, 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}]) diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index 1d848e8..4684bb0 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -129,6 +129,15 @@ async def test_skip_insert_when_configs_exist(self, mock_bot, mock_server): class TestFetchAchievementDataInBatches: """Test cases for _fetch_achievement_data_in_batches function.""" + @pytest.fixture(autouse=True) + def clear_cache(self): + """Clear the module-level achievement cache between tests.""" + from src.gw2.tools import gw2_utils + + gw2_utils._achievement_cache.clear() + yield + gw2_utils._achievement_cache.clear() + @pytest.mark.asyncio async def test_single_batch_under_200(self): """Test fetching with fewer than 200 achievements (single batch)."""