From 4e040ca8ad1742b9e5b893b3ef40320c97221d1f Mon Sep 17 00:00:00 2001 From: "Nyeki.py" <84998222+Nekidev@users.noreply.github.com> Date: Sat, 19 Oct 2024 00:39:33 -0300 Subject: [PATCH] feat(bot): :sparkles: Add servers leaderboard. --- payphone/bot.py | 10 ++- payphone/cogs/admin/cog.py | 85 ++++++++++++++++++- payphone/cogs/users/cog.py | 60 +++++++++---- .../models/10_20241018234951_update.py | 15 ++++ .../models/9_20241018234807_update.py | 11 +++ payphone/db/models.py | 14 +++ 6 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 payphone/db/migrations/models/10_20241018234951_update.py create mode 100644 payphone/db/migrations/models/9_20241018234807_update.py diff --git a/payphone/bot.py b/payphone/bot.py index 3013315..921dea0 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -19,6 +19,7 @@ class Payphone(commands.AutoShardedBot): def __init__(self) -> None: intents = discord.Intents.default() intents.message_content = True + intents.guilds = True if config.IS_DEBUG: intents.members = True @@ -280,7 +281,14 @@ class Payphone(commands.AutoShardedBot): prefix = prefix.decode() else: - server, _ = await models.Server.get_or_create(id=server_id) + server, _ = await models.Server.update_or_create( + ( + {"name": message.guild.name} + if message is not None and message.guild is not None + else None + ), + id=server_id, + ) await redis.set(key, server.prefix, ex=60 * 60) diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py index 22ad9de..794a6cd 100644 --- a/payphone/cogs/admin/cog.py +++ b/payphone/cogs/admin/cog.py @@ -1,7 +1,10 @@ -import discord -from discord.ext import commands +from datetime import datetime, timezone -from tortoise import transactions +import discord +from discord import errors +from discord.ext import commands, tasks + +from tortoise import transactions, functions, exceptions from payphone import bot from payphone.db import models, utils @@ -12,6 +15,82 @@ class Admin(base.Cog): def __init__(self, bot_instance: bot.Payphone): self.bot = bot_instance + self.update_leaderboard.add_exception_type(exceptions.ConfigurationError) + self.update_leaderboard.start() + + def cog_unload(self) -> None: + self.update_leaderboard.cancel() + + @tasks.loop(minutes=15) + async def update_leaderboard(self) -> None: + """Updates the leaderboard.""" + + calls = ( + await models.Call.annotate(messages_count=functions.Count("messages")) + .filter( + started_at__month=datetime.now(timezone.utc).month, + started_at__year=datetime.now(timezone.utc).year, + messages_count__gte=25, + ) + .prefetch_related( + "call_rss", "call_rss__channel", "call_rss__channel__server" + ) + ) + + # ID to count + servers_count_map: dict[int, int] = {} + + servers_map: dict[int, models.Server] = { + rs.channel.server_id: rs.channel.server + for call in calls + for rs in call.call_rss + } + + for call in calls: + for rs in call.call_rss: + if rs.channel.server_id in servers_count_map: + servers_count_map[rs.channel.server.id] += 1 + + else: + servers_count_map[rs.channel.server_id] = 0 + + server_counts = list(servers_count_map.items()) + + server_counts.sort(key=lambda i: i[1], reverse=True) + + for server_map in server_counts[:10]: + if servers_map[server_map[0]].name is None: + try: + servers_map[server_map[0]].name = ( + await self.bot.fetch_guild(server_map[0]) + ).name + + except errors.DiscordException: + pass + + for i, server_map in enumerate(server_counts): + servers_map[server_map[0]].leaderboard_position = i + 1 + servers_map[server_map[0]].leaderboard_calls_count = server_map[1] + + await models.Server.bulk_update( + servers_map.values(), + ["name", "leaderboard_position", "leaderboard_calls_count"], + 500, + ) + + @commands.Cog.listener() + async def on_guild_update( + self, _before: discord.Guild, after: discord.Guild + ) -> None: + """Updates a server. + + Args: + _before (discord.Guild): The server before it being updated. + after (discord.Guild): The server after it being updated. + """ + + await models.Server.update_or_create({"name": after.name}, id=after.id) + @commands.group(invoke_without_command=True) @commands.guild_only() @commands.has_guild_permissions(manage_guild=True) diff --git a/payphone/cogs/users/cog.py b/payphone/cogs/users/cog.py index eccb33a..a2e94cb 100644 --- a/payphone/cogs/users/cog.py +++ b/payphone/cogs/users/cog.py @@ -471,36 +471,62 @@ class Users(base.Cog): await ctx.send(embed=embed) - @commands.command(aliases=["l", "leader"]) - async def leaderboard(self, ctx: commands.Context) -> None: + @commands.command(aliases=["l", "leader", "ranking"]) + async def leaderboard(self, ctx: commands.Context, board: str = "users") -> None: """ Shows the leaderboard for the month. + + Options: + - `[]`: The board to show (users/servers). Defaults to users. """ - top_users = ( - await models.User.filter(leaderboard_position__lte=10) - .prefetch_related("profiles") - .order_by("leaderboard_position") - .limit(10) - ) + users_board_options = ["u", "user", "users"] + server_board_options = ["s", "server", "servers", "g", "guild", "guilds"] - user_rows = [ - f"**#{pos + 1} - {user.profiles[0].name}**{" (You!)" if user.id == ctx.author.id else ""}\nParticipated in {user.leaderboard_calls_count} calls." - for pos, user in enumerate(top_users) - ] + if board.lower() not in users_board_options + server_board_options: + await ctx.send( + f"❌ This leaderboard does not exist. The leaderboards are `servers` and `users`, for example `{ctx.clean_prefix}leaderboard users`." + ) + return + + table = "" + + if board.lower() in users_board_options: + top_users = ( + await models.User.filter(leaderboard_position__lte=10) + .prefetch_related("profiles") + .order_by("leaderboard_position") + .limit(10) + ) + user_rows = [ + f"**#{pos + 1} - {user.profiles[0].name}**{" (You!)" if user.id == ctx.author.id else ""}\nParticipated in {user.leaderboard_calls_count} calls." + for pos, user in enumerate(top_users) + ] + + table = "__**Top Users:**__\n\n" + "\n\n".join(user_rows) + + else: + top_servers = ( + await models.Server.filter(leaderboard_position__lte=10) + .order_by("leaderboard_position") + .limit(10) + ) + server_rows = [ + f"**#{pos + 1} - {server.name or "Updating..."}**{" (Here!)" if ctx.guild is not None and server.id == ctx.guild.id else ""}\nParticipated in {server.leaderboard_calls_count} calls." + for pos, server in enumerate(top_servers) + ] + + table = "__**Top Servers:**__\n\n" + "\n\n".join(server_rows) embed = discord.Embed( color=constants.EMBED_COLOR, title=f"Monthly Leaderboard ({datetime.now(timezone.utc).strftime("%B")})", description=( "Payphone's monthly leaderboard is based on the amount of calls you've " - "participated in with more than 25 messages sent (to prevent spam). The stats are " - "resetted every month.\n" - "\n" - "__**Top Users:**__\n" + "participated in with more than 25 messages sent (to prevent spam).\n" "\n" ) - + "\n\n".join(user_rows), + + table, author=self.bot.embed_author, footer=discord.EmbedFooter( text="The leaderboard is updated every 15 minutes." diff --git a/payphone/db/migrations/models/10_20241018234951_update.py b/payphone/db/migrations/models/10_20241018234951_update.py new file mode 100644 index 0000000..3ad169b --- /dev/null +++ b/payphone/db/migrations/models/10_20241018234951_update.py @@ -0,0 +1,15 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "server" ADD "leaderboard_calls_count" INT NOT NULL DEFAULT 0; + ALTER TABLE "server" ADD "leaderboard_position" INT NOT NULL DEFAULT 0; + CREATE INDEX "idx_server_leaderb_47c354" ON "server" ("leaderboard_position");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_server_leaderb_47c354"; + ALTER TABLE "server" DROP COLUMN "leaderboard_calls_count"; + ALTER TABLE "server" DROP COLUMN "leaderboard_position";""" diff --git a/payphone/db/migrations/models/9_20241018234807_update.py b/payphone/db/migrations/models/9_20241018234807_update.py new file mode 100644 index 0000000..cd4e4f6 --- /dev/null +++ b/payphone/db/migrations/models/9_20241018234807_update.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "server" ADD "name" VARCHAR(100);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "server" DROP COLUMN "name";""" diff --git a/payphone/db/models.py b/payphone/db/models.py index 9f3ea2b..fe51bab 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -4,6 +4,7 @@ import enum from tortoise import models, fields +import discord from discord import webhook import aiohttp @@ -72,13 +73,26 @@ class Profile(models.Model): return f"[{self.name}]({self.jump_url})" + @property + def embed_author(self) -> discord.EmbedAuthor: + """Returns this profile as an EmbedAuthor object. + + Returns: + discord.EmbedAuthor: The EmbedAuthor object. + """ + + return discord.EmbedAuthor(name=self.name, icon_url=self.avatar_url) + class Meta: unique_together = [("user_id", "server_id")] class Server(models.Model): id: int = fields.BigIntField(primary_key=True) + name: str = fields.CharField(max_length=100, null=True, default=None) prefix: str = fields.CharField(max_length=5, default="p.") + leaderboard_position: int = fields.IntField(default=0, db_index=True) + leaderboard_calls_count: int = fields.IntField(default=0) channels: fields.BackwardFKRelation["Channel"] profiles: fields.BackwardFKRelation["Profile"]