From a1e0d26cc5ba5a071d51980d1c42f7ef93c196dc Mon Sep 17 00:00:00 2001 From: Evanroby <107794516+Evanroby@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:02:20 +0100 Subject: [PATCH] [Counting]: Improvements + leaderboard command --- counting/commands/admin.py | 14 ++++++---- counting/commands/user.py | 56 ++++++++++++++++++++++++++++++++++++-- counting/event_handlers.py | 42 +++++++++++++++++----------- counting/utils.py | 4 +-- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/counting/commands/admin.py b/counting/commands/admin.py index 6e8ef01b..6968f22a 100644 --- a/counting/commands/admin.py +++ b/counting/commands/admin.py @@ -162,8 +162,8 @@ async def set_toggle_progress(self, ctx: commands.Context) -> None: async def set_toggle_progress_delete(self, ctx: commands.Context) -> None: """Toggle whether the goal message is deleted after being sent.""" settings = await self.settings.get_guild_settings(ctx.guild) - toggle = not settings["toggle_progress_delete"] - await self.settings.update_guild(ctx.guild, "toggle_progress_delete", toggle) + toggle = not settings["toggle_goal_delete"] + await self.settings.update_guild(ctx.guild, "toggle_goal_delete", toggle) await ctx.send(f"Goal message deletion is now {toggle and 'enabled' or 'disabled'}.") @countingset.group(name="messages") @@ -296,12 +296,12 @@ async def set_ruinrole( if not (60 <= duration_seconds <= 30 * 86400): return await ctx.send("Duration must be between 60 seconds and 30 days.") except ValueError: - return await ctx.send( - "Invalid duration. Use a number followed by 's', 'm', 'h', or 'd'." - ) logger.error( f"Invalid duration format '{duration}' provided by {ctx.author} in guild {ctx.guild.id}." ) + return await ctx.send( + "Invalid duration. Use a number followed by 's', 'm', 'h', or 'd'." + ) await asyncio.gather( self.settings.update_guild(ctx.guild, "ruin_role_id", role.id), self.settings.update_guild(ctx.guild, "ruin_role_duration", duration_seconds), @@ -391,7 +391,9 @@ async def set_goal( current_goals.append(goal) current_goals.sort() await self.settings.update_guild(ctx.guild, "goals", current_goals) - await ctx.send(f"Counting goal {goal} added. Current goals") + await ctx.send( + f"Counting goal {goal} added. Current goals: {', '.join(str(g) for g in current_goals)}." + ) else: await ctx.send(f"Goal {goal} is already set.") elif action.lower() == "remove": diff --git a/counting/commands/user.py b/counting/commands/user.py index 588f8e2e..3d755a0d 100644 --- a/counting/commands/user.py +++ b/counting/commands/user.py @@ -22,8 +22,6 @@ SOFTWARE. """ -import asyncio -from collections import defaultdict from datetime import datetime from typing import Optional @@ -68,6 +66,60 @@ async def stats(self, ctx: commands.Context, user: Optional[discord.Member] = No ) await ctx.send(f"Last counted: {time_str}\n{box(table, lang='prolog')}") + @counting.command(name="leaderboard", aliases=["lb"]) + @commands.cooldown(1, 10, commands.BucketType.guild) + @commands.bot_has_permissions(embed_links=True) + async def leaderboard(self, ctx: commands.Context) -> None: + """Show the counting leaderboard for the server. + + Displays the top 15 users with the highest counts. + Please note that the leaderboard only includes users who have counted at least once. + """ + user_cache: dict = self.settings._user_cache + entries = [] + for member in ctx.guild.members: + if member.bot: + continue + data = user_cache.get(member.id) + if data and data.get("count", 0) > 0: + entries.append((member, data["count"])) + if not entries: + return await ctx.send("No one has counted yet in this server.") + entries.sort(key=lambda x: x[1], reverse=True) + invoker_pos = next( + (i + 1 for i, (m, _) in enumerate(entries) if m.id == ctx.author.id), None + ) + footer_base = f"Total counters: {len(entries)}" + if invoker_pos and invoker_pos > 15: + invoker_count = next(c for m, c in entries if m.id == ctx.author.id) + footer_base += ( + f" · Your rank: #{invoker_pos} ({cf.humanize_number(invoker_count)} counts)" + ) + per_page = 15 + total_pages = max(1, -(-len(entries) // per_page)) + pages = [] + for page_num, i in enumerate(range(0, len(entries), per_page), start=1): + chunk = entries[i : i + per_page] + table_data = [ + [i + rank, member.display_name, cf.humanize_number(count)] + for rank, (member, count) in enumerate(chunk, start=1) + ] + table = tabulate( + table_data, + headers=["#", "User", "Count"], + tablefmt="simple", + stralign="left", + numalign="left", + ) + embed = discord.Embed( + title=f"🏆 Counting Leaderboard — {ctx.guild.name}", + description=box(table, lang="prolog"), + color=await ctx.embed_color(), + ) + embed.set_footer(text=f"Page {page_num}/{total_pages} · {footer_base}") + pages.append(embed) + await SimpleMenu(pages=pages, disable_after_timeout=True, timeout=120).start(ctx) + @counting.command(name="resetme", with_app_command=False) @commands.cooldown(1, 360, commands.BucketType.user) async def resetme(self, ctx: commands.Context) -> None: diff --git a/counting/event_handlers.py b/counting/event_handlers.py index 048a478c..1b222da7 100644 --- a/counting/event_handlers.py +++ b/counting/event_handlers.py @@ -148,19 +148,22 @@ async def on_message(self, message: discord.Message) -> None: cleaned_goals = [] if isinstance(goals, list): cleaned_goals = [int(g) for g in goals if isinstance(g, (int, float))] + if legacy_goal is not None: + migrated = False if isinstance(legacy_goal, (int, float)) and legacy_goal not in cleaned_goals: cleaned_goals.append(int(legacy_goal)) + migrated = True elif isinstance(legacy_goal, list): for g in legacy_goal: if isinstance(g, (int, float)) and g not in cleaned_goals: cleaned_goals.append(int(g)) + migrated = True + if migrated: + cleaned_goals = sorted(set(cleaned_goals)) + await self.settings.update_guild(message.guild, "goals", cleaned_goals) await self.settings.update_guild(message.guild, "goal", None) - if cleaned_goals: - cleaned_goals = sorted(set(cleaned_goals)) - await self.settings.update_guild(message.guild, "goals", cleaned_goals) - if cleaned_goals and expected_count in cleaned_goals: await self._handle_goal_reached(message, settings, expected_count) @@ -186,7 +189,9 @@ async def on_message(self, message: discord.Message) -> None: silent=settings["use_silent"], ) elif settings["allow_ruin"]: - await self._handle_count_ruin(message, settings) + await self._handle_count_ruin( + message.channel, message.guild, message.author, settings + ) else: response = settings["default_next_number_message"].format( next_count=expected_count @@ -195,26 +200,34 @@ async def on_message(self, message: discord.Message) -> None: message, response, settings, settings["toggle_next_number_message"] ) elif settings["allow_ruin"]: - await self._handle_count_ruin(message, settings) + await self._handle_count_ruin(message.channel, message.guild, message.author, settings) else: response = settings["default_next_number_message"].format(next_count=expected_count) await handle_invalid_count( message, response, settings, settings["toggle_next_number_message"] ) - async def _handle_count_ruin(self, message: discord.Message, settings: dict[str, Any]) -> None: + async def _handle_count_ruin( + self, + channel: discord.abc.Messageable, + guild: discord.Guild, + author: discord.Member | discord.Object, + settings: dict[str, Any], + ) -> None: old_count = settings["count"] await asyncio.gather( - self.settings.update_guild(message.guild, "count", 0), - self.settings.update_guild(message.guild, "last_user_id", None), + self.settings.update_guild(guild, "count", 0), + self.settings.update_guild(guild, "last_user_id", None), ) - await assign_ruin_role(self.settings.config, message.author, message.guild, settings) - response = settings["ruin_message"].format(user=message.author.mention, count=old_count) + if isinstance(author, discord.Member): + await assign_ruin_role(self.settings.config, author, guild, settings) + author_mention = getattr(author, "mention", f"<@{author.id}>") + response = settings["ruin_message"].format(user=author_mention, count=old_count) delete_after = ( settings["delete_after"] if settings.get("toggle_delete_after", False) else None ) await send_message( - message.channel, + channel, response, delete_after=delete_after, silent=settings["use_silent"], @@ -251,10 +264,7 @@ async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> N if settings["allow_ruin"]: author = guild.get_member(author_id) or discord.Object(id=author_id) - await self._handle_count_ruin( - discord.Message(state=channel._state, channel=channel, data=payload.data), - settings, - ) + await self._handle_count_ruin(channel, guild, author, settings) elif settings["toggle_edit_message"]: response = settings["default_edit_message"].format(next_count=settings["count"] + 1) delete_after = ( diff --git a/counting/utils.py b/counting/utils.py index 9594e90e..276c26cc 100644 --- a/counting/utils.py +++ b/counting/utils.py @@ -82,7 +82,7 @@ async def handle_invalid_count( await delete_message(message) if send_response: delete_after = ( - settings["delete_after"] if settings.get("toggle_delete_after", True) else None + settings["delete_after"] if settings.get("toggle_delete_after", False) else None ) await send_message( message.channel, @@ -105,7 +105,7 @@ async def assign_ruin_role( role = guild.get_role(ruin_role_id) if not role or role >= guild.me.top_role: - logger.warning(f"Cannot assign ruin role {role.name} in {guild.name} ({guild.id})") + logger.warning(f"Cannot assign ruin role {ruin_role_id} in {guild.name} ({guild.id})") return if any(r.id in excluded_role_ids for r in member.roles):