Ruby-Cogs/leaderboard/leaderboard.py

357 lines
No EOL
14 KiB
Python

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 = {
"min_message_length": 5,
"cooldown": 60, # Seconds between updates
}
self.config.register_guild(**default_guild)
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,<your_secret>")
return False
# Initialize aiohttp session
if not self.session:
self.session = aiohttp.ClientSession()
return True
async def get_user_credits(self, user: discord.Member) -> int:
"""Get a user's total credits from Red's bank system."""
try:
if await bank.is_global():
credits = await bank.get_balance(user)
else:
credits = await bank.get_balance(user, _forced=True)
return credits
except Exception as e:
log.error(f"Error getting bank balance for {user}: {e}")
return 0
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_credits(self, user_id: str, username: str, credits: int = None) -> bool:
"""Update a user's credits 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 credits not provided, get from Red's bank
if credits 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
credits = await self.get_user_credits(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": credits # API still uses "points" field
}
) 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 credits: Status {resp.status}")
return False
except Exception as e:
log.error(f"Error updating credits: {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 credits 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 Credits 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")
credits = entry.get("points", 0) # API returns as "points"
# Format each entry with position, name, credits, and user ID
description.append(
f"`{i}.` <@{user_id}> • **{humanize_number(credits)}** credits"
)
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="credits")
async def check_credits(self, ctx: commands.Context, member: commands.MemberConverter = None):
"""Check your credits or another member's credits."""
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 Credits",
color=await ctx.embed_color()
)
embed.description = (
f"**User:** <@{member.id}>\n"
f"**Credits:** {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 Credits Found",
description=f"<@{member.id}> has no credits yet!",
color=await ctx.embed_color()
)
embed.set_footer(text=f"ID: {member.id}")
await ctx.send(embed=embed)
@globalboard.command(name="resync")
@commands.is_owner()
async def resync_leaderboard(self, ctx: commands.Context):
"""Force a resync of all users' credits with the global leaderboard."""
async with ctx.typing():
success_count = 0
fail_count = 0
# Clear cache
self._cache.clear()
self._last_update.clear()
# Update all members across all guilds
for guild in self.bot.guilds:
for member in guild.members:
if member.bot:
continue
if await self._update_credits(str(member.id), str(member)):
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)
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Update credits when member's data changes."""
if before.bot:
return
# Update credits in API
await self._update_credits(str(after.id), str(after))
@commands.Cog.listener()
async def on_bank_update(self, member: discord.Member, before: int, after: int):
"""Update credits when a member's bank balance changes."""
if member.bot:
return
# Update credits in API with the new balance
await self._update_credits(str(member.id), str(member), after)