From c08a40c45f7c91a5522cbf2430c134fd286519f4 Mon Sep 17 00:00:00 2001 From: "Nyeki.py" <84998222+Nekidev@users.noreply.github.com> Date: Fri, 18 Oct 2024 03:05:03 -0300 Subject: [PATCH] feat(bot): :sparkles: Create `p.leaderboard` command. --- compose.yml | 4 + payphone/cogs/users/cog.py | 177 +++++++++++++++++- payphone/core/formatting.py | 43 +++-- .../models/4_20241018014134_update.py | 17 ++ .../models/5_20241018014615_update.py | 11 ++ payphone/db/models.py | 5 + payphone/db/utils.py | 16 +- 7 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 payphone/db/migrations/models/4_20241018014134_update.py create mode 100644 payphone/db/migrations/models/5_20241018014615_update.py diff --git a/compose.yml b/compose.yml index aaedd18..85d0628 100644 --- a/compose.yml +++ b/compose.yml @@ -20,10 +20,14 @@ services: volumes: - database:/var/lib/postgresql/data command: postgres -c 'max_connections=1000' + # ports: + # - 5432:5432 restart: always redis: image: redis:7.4.1-alpine + # ports: + # - 6379:6379 restart: always volumes: diff --git a/payphone/cogs/users/cog.py b/payphone/cogs/users/cog.py index 6011f9b..4fb2568 100644 --- a/payphone/cogs/users/cog.py +++ b/payphone/cogs/users/cog.py @@ -1,19 +1,65 @@ +from datetime import datetime, timezone + import discord -from discord.ext import commands, pages +from discord.ext import commands, pages, tasks from discord.utils import find -from tortoise import transactions, functions +from tortoise import transactions, functions, expressions, exceptions from payphone import bot from payphone.db import models, utils from payphone.cogs import base -from payphone.core import constants +from payphone.core import constants, formatting as fmt class Users(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__user") + ) + + # ID to count + users_count_map: dict[int, int] = {} + + users_map: dict[int, models.User] = { + rs.user.id: rs.user for call in calls for rs in call.call_rss + } + + for call in calls: + for rs in call.call_rss: + if rs.user_id in users_count_map: + users_count_map[rs.user_id] += 1 + + else: + users_count_map[rs.user_id] = 0 + + user_counts = list(users_count_map.items()) + + user_counts.sort(key=lambda i: i[1]) + + for i, user_id in enumerate(user_counts): + users_map[user_id[0]].leaderboard_position = i + 1 + + await models.User.bulk_update(users_map.values(), ["leaderboard_position"], 500) + @commands.command() @transactions.atomic() async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None: @@ -247,6 +293,131 @@ class Users(base.Cog): await paginator.send(ctx) + @commands.command() + @transactions.atomic() + async def me(self, ctx: commands.Context) -> None: + """ + Shows your statistics. + """ + + user, profile = await utils.get_user(ctx.author) + + messages_count = await models.Message.filter(user=user).count() + + channel_ids = ( + await models.CallUserChannelRelationship.filter(user=user) + .distinct() + .values_list("channel_id", flat=True) + ) + call_ids = ( + await models.CallUserChannelRelationship.filter(user=user) + .distinct() + .values_list("call_id", flat=True) + ) + + people_met_count = await models.CallUserChannelRelationship.filter( + call_id__in=call_ids, channel_id__not_in=channel_ids + ).count() + ban = ( + await models.Ban.filter( + expressions.Q( + expires_at__gt=datetime.now(timezone.utc), + expires_at=None, + join_type="OR", + ), + user_id=user.id, + ) + .limit(1) + .first() + ) + + embed = discord.Embed( + color=constants.EMBED_COLOR, + timestamp=datetime.now(timezone.utc), + fields=[ + discord.EmbedField( + name="Messages", value=str(messages_count), inline=True + ), + discord.EmbedField(name="Calls", value=len(call_ids), inline=True), + discord.EmbedField( + name="People Met", value=people_met_count, inline=True + ), + discord.EmbedField( + name="Joined At", + value=f" ", + inline=True, + ), + discord.EmbedField( + name="Leaderboard", + value=( + f"#{user.leaderboard_position} (Monthly)" + if user.leaderboard_position is not None + else "*No calls yet.*" + ), + inline=True, + ), + discord.EmbedField( + name="Banned", + value=( + "*Not banned.*" + if ban is None + else ( + "*Permanent.*" + if ban.expires_at is None + else f" ()" + ) + ), + inline=True, + ), + ], + author=discord.EmbedAuthor(name=profile.name, icon_url=profile.avatar_url), + footer=discord.EmbedFooter( + text="Thank you for using Payphone! Leaderboard updates every 15 minutes." + ), + thumbnail=profile.avatar_url, + ) + + await ctx.send(embed=embed) + + @commands.command(aliases=["l", "leader"]) + async def leaderboard(self, ctx: commands.Context) -> None: + """ + Shows the leaderboard for the month. + """ + + top_users = ( + await models.User.filter(leaderboard_position__lte=10) + .prefetch_related("profiles") + .order_by("leaderboard_position") + .limit(10) + ) + + rows = [ + [f"#{pos + 1}", fmt.trim(user.profiles[0].name, 10)] + for pos, user in enumerate(top_users) + ] + + if not any(map(lambda u: u.id == ctx.author.id, top_users)): + user, profile = await utils.get_user(ctx.author) + + if user.leaderboard_position is not None: + rows.append([f"#{user.leaderboard_position}", fmt.trim(profile.name, 10)]) + + table = fmt.monospace_table(rows) + + embed = discord.Embed( + color=constants.EMBED_COLOR, + title=f"Monthly Leaderboard ({datetime.now(timezone.utc).strftime("%B")})", + description=f"```\n{table}\n```", + author=self.bot.embed_author, + footer=discord.EmbedFooter( + text="The leaderboard is updated every 15 minutes." + ), + thumbnail=self.bot.user.display_avatar.url, + ) + + await ctx.send(embed=embed) + def setup(b: bot.Payphone) -> None: """Load the cog into the bot. diff --git a/payphone/core/formatting.py b/payphone/core/formatting.py index 7a12dec..c451f98 100644 --- a/payphone/core/formatting.py +++ b/payphone/core/formatting.py @@ -83,22 +83,37 @@ def monospace_table( str: The monospace table. """ - max_sizes = [ - max( - *( - [ - ( - len(str(row[i])) - if not isinstance(row[i], tuple) - else (sum(len(str(item)) for item in row[i]) + 1) - ) - for row in rows - ] - + ([len(headers[i])] if headers is not None else []) + max_sizes = ( + [ + max( + *( + [ + ( + len(str(row[i])) + if not isinstance(row[i], tuple) + else (sum(len(str(item)) for item in row[i]) + 1) + ) + for row in rows + ] + + ([len(headers[i])] if headers is not None else []) + ) ) + for i in range(len(rows[0])) + ] + if len(rows) >= 2 + else ( + [ + ( + len(str(col)) + if not isinstance(col, tuple) + else (sum(len(str(item)) for item in col) + 1) + ) + for col in rows[0] + ] + if len(rows) > 0 + else [] ) - for i in range(len(rows[0])) - ] + ) def format_row(row: list) -> str: return column_separator.join( diff --git a/payphone/db/migrations/models/4_20241018014134_update.py b/payphone/db/migrations/models/4_20241018014134_update.py new file mode 100644 index 0000000..38e43b9 --- /dev/null +++ b/payphone/db/migrations/models/4_20241018014134_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "calluserchannelrelationship" ADD "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; + ALTER TABLE "user" ADD "leaderboard_position" INT; + CREATE INDEX "idx_callusercha_created_e72939" ON "calluserchannelrelationship" ("created_at"); + CREATE INDEX "idx_user_leaderb_1dcee5" ON "user" ("leaderboard_position");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_callusercha_created_e72939"; + DROP INDEX "idx_user_leaderb_1dcee5"; + ALTER TABLE "user" DROP COLUMN "leaderboard_position"; + ALTER TABLE "calluserchannelrelationship" DROP COLUMN "created_at";""" diff --git a/payphone/db/migrations/models/5_20241018014615_update.py b/payphone/db/migrations/models/5_20241018014615_update.py new file mode 100644 index 0000000..7c58be8 --- /dev/null +++ b/payphone/db/migrations/models/5_20241018014615_update.py @@ -0,0 +1,11 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "user" ADD "joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "user" DROP COLUMN "joined_at";""" diff --git a/payphone/db/models.py b/payphone/db/models.py index 59e7b86..dc323d3 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -18,6 +18,10 @@ class User(models.Model): is_moderator: bool = fields.BooleanField(default=False) profiles: fields.BackwardFKRelation["Profile"] call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] + leaderboard_position: int | None = fields.IntField( + default=None, null=True, db_index=True + ) + joined_at: datetime = fields.DatetimeField(auto_now_add=True) class Call(models.Model): @@ -130,6 +134,7 @@ class CallUserChannelRelationship(models.Model): ) is_masked: bool = fields.BooleanField() mask: int = fields.BigIntField() + created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True) @property def mask_name(self) -> str: diff --git a/payphone/db/utils.py b/payphone/db/utils.py index d6e81eb..af8bd04 100644 --- a/payphone/db/utils.py +++ b/payphone/db/utils.py @@ -5,9 +5,7 @@ import discord from payphone.db import models -async def get_channel( - channel: discord.TextChannel, for_update: bool = False -) -> models.Channel: +async def get_channel(channel: discord.TextChannel, for_update: bool = False) -> models.Channel: """Returns a channel from the database, creating or updating it if it already exists. Args: @@ -18,15 +16,9 @@ async def get_channel( models.Channel: The channel row. """ - model = models.Channel - - if for_update: - model = model.select_for_update() - - row = await model.get_or_none(id=channel.id) - - if row is None: - row = await models.Channel.create(id=channel.id, server_id=channel.guild.id) + row, _ = await models.Channel.update_or_create( + id=channel.id, server=channel.guild.id + ) channel_name = channel.name.replace("discord", "d").replace("wumpus", "w") webhook_name = f"Payphone #{channel_name}"