from redbot.core import commands, Config, bank from redbot.core.bot import Red from redbot.core.utils.chat_formatting import box, pagify, humanize_number import discord from discord.ui import Button, View import aiohttp import asyncio import logging from datetime import datetime, timezone from typing import Dict, Optional, List log = logging.getLogger("red.ruby.leaderboard") class LeaderboardView(View): def __init__(self, cog, ctx, pages: List[discord.Embed], timeout: float = 180.0): super().__init__(timeout=timeout) self.cog = cog self.ctx = ctx self.pages = pages self.current_page = 0 self.message = None # Add buttons self.first_page.disabled = True self.prev_page.disabled = True if len(self.pages) == 1: self.next_page.disabled = True self.last_page.disabled = True @discord.ui.button(label="≪", style=discord.ButtonStyle.gray) async def first_page(self, interaction: discord.Interaction, button: discord.ui.Button): self.current_page = 0 await self.update_page(interaction) @discord.ui.button(label="Previous", style=discord.ButtonStyle.blurple) async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): self.current_page = max(0, self.current_page - 1) await self.update_page(interaction) @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): self.current_page = min(len(self.pages) - 1, self.current_page + 1) await self.update_page(interaction) @discord.ui.button(label="≫", style=discord.ButtonStyle.gray) async def last_page(self, interaction: discord.Interaction, button: discord.ui.Button): self.current_page = len(self.pages) - 1 await self.update_page(interaction) async def update_page(self, interaction: discord.Interaction): # Update button states self.first_page.disabled = self.current_page == 0 self.prev_page.disabled = self.current_page == 0 self.next_page.disabled = self.current_page == len(self.pages) - 1 self.last_page.disabled = self.current_page == len(self.pages) - 1 await interaction.response.edit_message(embed=self.pages[self.current_page], view=self) async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user.id == self.ctx.author.id: return True await interaction.response.send_message("This menu is not for you!", ephemeral=True) return False async def on_timeout(self): try: for item in self.children: item.disabled = True await self.message.edit(view=self) except discord.HTTPException: pass class Leaderboard(commands.Cog): """Global leaderboard system for Ruby.""" def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=867530999, force_registration=True) self.session = None # Initialize in setup self.api_base_url = "https://ruby.valerie.lol/api" self.admin_secret = None self.cache_time = 300 # 5 minutes cache self._cache = {} self._last_update = {} # Default settings default_guild = { "points_per_message": 1, "points_decay": 0.5, # Points lost per day of inactivity "min_message_length": 5, "cooldown": 60, # Seconds between point gains } self.config.register_guild(**default_guild) def cog_unload(self): if self.session: asyncio.create_task(self.session.close()) async def initialize(self): """Load the admin secret from bot config and initialize session.""" self.admin_secret = await self.bot.get_shared_api_tokens("ruby_api") if not self.admin_secret.get("admin_secret"): log.error("No admin secret found. Please set it using [p]set api ruby_api admin_secret,") return False # Initialize aiohttp session if not self.session: self.session = aiohttp.ClientSession() return True async def get_user_points(self, user: discord.Member) -> int: """Get a user's total points combining bank balance and levelup XP.""" total_points = 0 # Get bank balance (credits) try: if await bank.is_global(): balance = await bank.get_balance(user) else: balance = await bank.get_balance(user, _forced=True) total_points += balance except Exception as e: log.error(f"Error getting bank balance for {user}: {e}") # Get LevelUp XP if cog is loaded levelup = self.bot.get_cog("LevelUp") if levelup: try: profile = await levelup.get_user_profile(user) if profile: total_points += int(profile.total_xp) except Exception as e: log.error(f"Error getting LevelUp XP for {user}: {e}") return total_points async def _get_leaderboard(self) -> Optional[list]: """Fetch the global leaderboard from the API.""" if not self.admin_secret or not self.admin_secret.get("admin_secret"): log.error("Admin secret not configured") return None if not self.session: log.error("Session not initialized") return None try: now = datetime.now(timezone.utc).timestamp() # Return cached data if available and fresh if self._cache and now - self._last_update.get("leaderboard", 0) < self.cache_time: return self._cache.get("leaderboard") async with self.session.get( f"{self.api_base_url}/leaderboard", headers={"Authorization": self.admin_secret.get("admin_secret")} ) as resp: if resp.status == 200: data = await resp.json() if not isinstance(data, dict) or "leaderboard" not in data: log.error("Invalid API response format") return None self._cache["leaderboard"] = data["leaderboard"] self._last_update["leaderboard"] = now return self._cache["leaderboard"] elif resp.status == 401: log.error("Unauthorized: Invalid admin secret") return None else: log.error(f"Failed to fetch leaderboard: Status {resp.status}") return None except Exception as e: log.error(f"Error fetching leaderboard: {e}") return None async def _update_points(self, user_id: str, username: str, points: int = None) -> bool: """Update a user's points in the global leaderboard.""" if not self.admin_secret or not self.admin_secret.get("admin_secret"): log.error("Admin secret not configured") return False if not self.session: log.error("Session not initialized") return False try: # If points not provided, calculate from Red's systems if points is None: member = None for guild in self.bot.guilds: member = guild.get_member(int(user_id)) if member: break if not member: return False points = await self.get_user_points(member) async with self.session.post( f"{self.api_base_url}/leaderboard", headers={ "Authorization": self.admin_secret.get("admin_secret"), "Content-Type": "application/json" }, json={ "userId": user_id, "username": username, "points": points } ) as resp: if resp.status == 200: # Clear cache to ensure fresh data on next fetch self._cache.pop("leaderboard", None) return True elif resp.status == 401: log.error("Unauthorized: Invalid admin secret") return False else: log.error(f"Failed to update points: Status {resp.status}") return False except Exception as e: log.error(f"Error updating points: {e}") return False @commands.group(name="globalboard", aliases=["glb"]) async def globalboard(self, ctx: commands.Context): """Global leaderboard commands.""" if ctx.invoked_subcommand is None: # Let the default help command handle it return @globalboard.command(name="show") async def show_leaderboard(self, ctx: commands.Context, page: int = 1): """Show the global leaderboard.""" async with ctx.typing(): leaderboard_data = await self._get_leaderboard() if not leaderboard_data: if not self.admin_secret or not self.admin_secret.get("admin_secret"): return await ctx.send("Leaderboard is not configured. Please contact the bot administrator.") return await ctx.send("Failed to fetch leaderboard data. Please try again later.") if not leaderboard_data: # Empty leaderboard return await ctx.send("The leaderboard is currently empty!") items_per_page = 10 chunks = [leaderboard_data[i:i + items_per_page] for i in range(0, len(leaderboard_data), items_per_page)] if not chunks: # No data after pagination return await ctx.send("The leaderboard is currently empty!") embeds = [] for page_num, entries in enumerate(chunks, 1): embed = discord.Embed( title="🏆 Global Leaderboard", color=await ctx.embed_color() ) # Format leaderboard entries description = [] start_pos = (page_num - 1) * items_per_page for i, entry in enumerate(entries, start=start_pos + 1): username = entry.get("username", "Unknown User") user_id = entry.get("userId", "0") points = entry.get("points", 0) # Format each entry with position, name, points, and user ID description.append( f"`{i}.` <@{user_id}> • **{humanize_number(points)}** points" ) embed.description = "\n".join(description) embed.set_footer(text=f"Page {page_num}/{len(chunks)} • Total Users: {len(leaderboard_data)}") embeds.append(embed) view = LeaderboardView(self, ctx, embeds) view.message = await ctx.send(embed=embeds[0], view=view) @globalboard.command(name="points") async def check_points(self, ctx: commands.Context, member: commands.MemberConverter = None): """Check your points or another member's points.""" member = member or ctx.author leaderboard_data = await self._get_leaderboard() if not leaderboard_data: if not self.admin_secret or not self.admin_secret.get("admin_secret"): return await ctx.send("Leaderboard is not configured. Please contact the bot administrator.") return await ctx.send("Failed to fetch leaderboard data. Please try again later.") user_data = next( (entry for entry in leaderboard_data if entry.get("userId") == str(member.id)), None ) if user_data: rank = next( (i for i, entry in enumerate(leaderboard_data, 1) if entry.get("userId") == str(member.id)), None ) embed = discord.Embed( title="🏆 Global Points", color=await ctx.embed_color() ) embed.description = ( f"**User:** <@{member.id}>\n" f"**Points:** {humanize_number(user_data.get('points', 0))}\n" f"**Rank:** #{humanize_number(rank)}\n" f"**ID:** {member.id}" ) embed.set_thumbnail(url=member.display_avatar.url) await ctx.send(embed=embed) else: embed = discord.Embed( title="No Points Found", description=f"<@{member.id}> has no points yet!", color=await ctx.embed_color() ) embed.set_footer(text=f"ID: {member.id}") await ctx.send(embed=embed) @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """Update points when member's data changes.""" if before.bot: return # Update points in API when bank balance or XP changes await self._update_points(str(after.id), str(after)) @commands.Cog.listener() async def on_message(self, message: discord.Message): """Update points periodically during user activity.""" if message.author.bot or not message.guild: return # Get guild settings guild_settings = await self.config.guild(message.guild).all() min_length = guild_settings["min_message_length"] cooldown = guild_settings["cooldown"] # Check message length if len(message.content) < min_length: return # Check cooldown now = datetime.now(timezone.utc).timestamp() last_msg_time = self._last_update.get(f"msg_{message.author.id}", 0) if now - last_msg_time < cooldown: return self._last_update[f"msg_{message.author.id}"] = now # Update points in API await self._update_points(str(message.author.id), str(message.author))