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
12 changes: 11 additions & 1 deletion src/gw2/cogs/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`"
)
Expand Down Expand Up @@ -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


Expand Down
55 changes: 31 additions & 24 deletions src/gw2/tools/gw2_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/gw2/cogs/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}])
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/gw2/tools/test_gw2_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down