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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
102 changes: 77 additions & 25 deletions src/gw2/cogs/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"]
Expand All @@ -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()
Expand All @@ -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))
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -176,6 +191,43 @@ 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 gw2_messages.GW2_FULL_NAME.lower() 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:
Expand Down
1 change: 1 addition & 0 deletions src/gw2/constants/gw2_messages.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
GW2_FULL_NAME = "Guild Wars 2"
#################################
# EVENT ON COMMAND ERROR
#################################
Expand Down
95 changes: 62 additions & 33 deletions src/gw2/tools/gw2_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}


Expand Down Expand Up @@ -307,13 +307,26 @@ 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 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}: "
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:
Expand All @@ -326,47 +339,63 @@ 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()
)


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}, 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:
Expand Down
Loading