diff --git a/leaderboard/leaderboard.py b/leaderboard/leaderboard.py index 0d54464..86d3a0d 100644 --- a/leaderboard/leaderboard.py +++ b/leaderboard/leaderboard.py @@ -82,6 +82,9 @@ class Leaderboard(commands.Cog): self.api_base_url = "https://ruby.valerie.lol/api" self.admin_secret = None + # Add task for auto-sync + self.auto_sync_task = None + default_guild = { "min_message_length": 5, "cooldown": 60, @@ -95,7 +98,7 @@ class Leaderboard(commands.Cog): self.config.register_user(**default_user) async def initialize(self): - """Initialize optional API connection.""" + """Initialize optional API connection and start auto-sync task.""" try: self.admin_secret = await self.bot.get_shared_api_tokens("ruby_api") if self.admin_secret.get("admin_secret"): @@ -107,9 +110,56 @@ class Leaderboard(commands.Cog): ) as resp: if resp.status != 200: log.error(f"Failed to connect to API: Status {resp.status}") + else: + # Start auto-sync task if API connection successful + self.auto_sync_task = self.bot.loop.create_task(self._auto_sync_task()) + log.info("Started automatic leaderboard sync task") except Exception as e: log.error(f"Failed to initialize API connection: {e}") + async def _auto_sync_task(self): + """Task to automatically sync leaderboard every 6 hours.""" + await self.bot.wait_until_ready() + while True: + try: + await self._perform_full_sync() + log.info("Completed automatic leaderboard sync") + await asyncio.sleep(21600) # 6 hours in seconds + except asyncio.CancelledError: + break + except Exception as e: + log.error(f"Error in automatic sync task: {e}") + await asyncio.sleep(300) # Wait 5 minutes before retrying on error + + async def _perform_full_sync(self) -> tuple[int, int]: + """Perform a full sync of all users to the API. + Returns tuple of (success_count, fail_count)""" + success_count = 0 + fail_count = 0 + processed_users = set() + + for guild in self.bot.guilds: + for member in guild.members: + if member.bot or member.id in processed_users: + continue + + try: + # Get total credits across all guilds + credits = await self.get_user_credits(member) + + # Update API + if await self._try_api_update(str(member.id), str(member), credits): + success_count += 1 + else: + fail_count += 1 + + processed_users.add(member.id) + except Exception as e: + log.error(f"Error syncing user {member} ({member.id}): {e}") + fail_count += 1 + + return success_count, fail_count + async def cog_load(self): """Initialize the cog when it's loaded.""" await self.initialize() @@ -118,54 +168,60 @@ class Leaderboard(commands.Cog): """Get a user's total credits from all servers.""" total_credits = 0 - # Get credits from all servers the user is in - for guild in self.bot.guilds: - if user in guild.members: # Only check servers where the user is a member - try: - credits = await bank.get_balance(user) - total_credits += credits - except Exception as e: - log.error(f"Error getting bank balance for {user} in {guild}: {e}") - continue - + try: + # Get credits from the user's current server first + total_credits = await bank.get_balance(user) + + # Then add credits from all other servers where they are a member + for guild in self.bot.guilds: + if guild != user.guild and user in guild.members: # Skip current guild since we already got those credits + try: + guild_credits = await bank.get_balance(user, guild) + total_credits += guild_credits + except Exception as e: + log.error(f"Error getting bank balance for {user} in {guild}: {e}") + continue + + except Exception as e: + log.error(f"Error getting total credits for {user}: {e}") + return total_credits async def get_all_balances(self) -> List[dict]: """Get all users' credit balances across all servers.""" all_users = {} - min_credits = 10000 # Minimum credits to show on leaderboard + min_credits = 10000 # Minimum credits to show on leaderboard - matches API's MIN_CREDITS - # Collect all unique members and sum their balances across all guilds + # First get all unique members across all guilds + all_members = set() for guild in self.bot.guilds: for member in guild.members: - if member.bot: - continue - - # Skip opted-out users - if await self.config.user(member).opted_out(): - continue - - try: - # Get balance for this guild - credits = await bank.get_balance(member) - - if member.id not in all_users: - all_users[member.id] = { - "userId": str(member.id), - "username": str(member), - "points": credits - } - else: - # Add this guild's balance to user's total - all_users[member.id]["points"] += credits - - except Exception as e: - log.error(f"Error getting balance for {member} in {guild}: {e}") - continue + if not member.bot: + all_members.add(member) - # Filter out users with less than minimum credits and sort by total + # Now get total credits for each unique member + for member in all_members: + # Skip opted-out users + if await self.config.user(member).opted_out(): + continue + + try: + total_credits = await self.get_user_credits(member) + + if total_credits >= min_credits: + all_users[member.id] = { + "userId": str(member.id), + "username": str(member), + "points": total_credits + } + + except Exception as e: + log.error(f"Error getting balance for {member}: {e}") + continue + + # Sort by total credits sorted_users = sorted( - [user for user in all_users.values() if user["points"] >= min_credits], + all_users.values(), key=lambda x: x["points"], reverse=True ) @@ -180,6 +236,9 @@ class Leaderboard(commands.Cog): try: if credits < 0: credits = 0 # API doesn't accept negative values + + # Get opt-out status + opted_out = await self.config.user_from_id(int(user_id)).opted_out() async with self.session.post( f"{self.api_base_url}/leaderboard", @@ -190,13 +249,14 @@ class Leaderboard(commands.Cog): json={ "userId": str(user_id), "username": str(username), - "points": credits + "points": credits, + "optedOut": opted_out } ) as resp: if resp.status == 200: data = await resp.json() if data.get("success"): - log.info(f"Updated leaderboard for {username}: {credits} credits") + log.info(f"Updated leaderboard for {username}: {credits} credits (opted_out: {opted_out})") return True else: log.error(f"API update failed: {data.get('error', 'Unknown error')}") @@ -293,6 +353,11 @@ class Leaderboard(commands.Cog): return await user_settings.opted_out.set(True) + + # Update API with new opt-out status + credits = await self.get_user_credits(ctx.author) + await self._try_api_update(str(ctx.author.id), str(ctx.author), credits) + await ctx.send("You have been opted out of the global leaderboard. Your credits will no longer be visible.") @globalboard.command(name="optin") @@ -306,6 +371,11 @@ class Leaderboard(commands.Cog): return await user_settings.opted_out.set(False) + + # Update API with new opt-in status + credits = await self.get_user_credits(ctx.author) + await self._try_api_update(str(ctx.author.id), str(ctx.author), credits) + await ctx.send("You have been opted back into the global leaderboard. Your credits will now be visible.") @globalboard.command(name="credits") @@ -353,30 +423,28 @@ class Leaderboard(commands.Cog): @commands.is_owner() async def resync_leaderboard(self, ctx: commands.Context): """Force a resync of all users' credits with the API (if configured).""" + if not self.session or not self.admin_secret or not self.admin_secret.get("admin_secret"): + await ctx.send("API is not configured.") + return + async with ctx.typing(): - success_count = 0 - fail_count = 0 + message = await ctx.send("🔄 Starting global leaderboard resync...") - for guild in self.bot.guilds: - for member in guild.members: - if member.bot: - continue - - credits = await self.get_user_credits(member) - if await self._try_api_update(str(member.id), str(member), credits): - success_count += 1 - else: - fail_count += 1 - - embed = discord.Embed( - title="🔄 Global Leaderboard Resync Complete", - description=( - f"Successfully updated: **{success_count}** users\n" - f"Failed to update: **{fail_count}** users" - ), - color=await ctx.embed_color() - ) - await ctx.send(embed=embed) + try: + success_count, fail_count = await self._perform_full_sync() + + embed = discord.Embed( + title="🔄 Global Leaderboard Resync Complete", + description=( + f"Successfully updated: **{success_count}** users\n" + f"Failed to update: **{fail_count}** users" + ), + color=await ctx.embed_color(), + timestamp=datetime.now(timezone.utc) + ) + await message.edit(content=None, embed=embed) + except Exception as e: + await message.edit(content=f"❌ Error during resync: {str(e)}") @commands.Cog.listener() async def on_bank_update(self, member: discord.Member, before: int, after: int): @@ -419,4 +487,6 @@ class Leaderboard(commands.Cog): def cog_unload(self): """Clean up when cog is unloaded.""" if self.session: - asyncio.create_task(self.session.close()) \ No newline at end of file + asyncio.create_task(self.session.close()) + if self.auto_sync_task: + self.auto_sync_task.cancel() \ No newline at end of file