feat(bot): ✨ Create p.leaderboard command.

This commit is contained in:
Nyeki.py
2024-10-18 03:05:03 -03:00
parent f265e12cb4
commit c08a40c45f
7 changed files with 244 additions and 29 deletions
+4
View File
@@ -20,10 +20,14 @@ services:
volumes: volumes:
- database:/var/lib/postgresql/data - database:/var/lib/postgresql/data
command: postgres -c 'max_connections=1000' command: postgres -c 'max_connections=1000'
# ports:
# - 5432:5432
restart: always restart: always
redis: redis:
image: redis:7.4.1-alpine image: redis:7.4.1-alpine
# ports:
# - 6379:6379
restart: always restart: always
volumes: volumes:
+174 -3
View File
@@ -1,19 +1,65 @@
from datetime import datetime, timezone
import discord import discord
from discord.ext import commands, pages from discord.ext import commands, pages, tasks
from discord.utils import find from discord.utils import find
from tortoise import transactions, functions from tortoise import transactions, functions, expressions, exceptions
from payphone import bot from payphone import bot
from payphone.db import models, utils from payphone.db import models, utils
from payphone.cogs import base from payphone.cogs import base
from payphone.core import constants from payphone.core import constants, formatting as fmt
class Users(base.Cog): class Users(base.Cog):
def __init__(self, bot_instance: bot.Payphone): def __init__(self, bot_instance: bot.Payphone):
self.bot = bot_instance 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() @commands.command()
@transactions.atomic() @transactions.atomic()
async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None: async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None:
@@ -247,6 +293,131 @@ class Users(base.Cog):
await paginator.send(ctx) 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"<t:{int(user.joined_at.timestamp())}:d> <t:{int(user.joined_at.timestamp())}:T>",
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"<t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
)
),
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: def setup(b: bot.Payphone) -> None:
"""Load the cog into the bot. """Load the cog into the bot.
+29 -14
View File
@@ -83,22 +83,37 @@ def monospace_table(
str: The monospace table. str: The monospace table.
""" """
max_sizes = [ max_sizes = (
max( [
*( max(
[ *(
( [
len(str(row[i])) (
if not isinstance(row[i], tuple) len(str(row[i]))
else (sum(len(str(item)) for item in row[i]) + 1) if not isinstance(row[i], tuple)
) else (sum(len(str(item)) for item in row[i]) + 1)
for row in rows )
] for row in rows
+ ([len(headers[i])] if headers is not None else []) ]
+ ([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: def format_row(row: list) -> str:
return column_separator.join( return column_separator.join(
@@ -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";"""
@@ -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";"""
+5
View File
@@ -18,6 +18,10 @@ class User(models.Model):
is_moderator: bool = fields.BooleanField(default=False) is_moderator: bool = fields.BooleanField(default=False)
profiles: fields.BackwardFKRelation["Profile"] profiles: fields.BackwardFKRelation["Profile"]
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] 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): class Call(models.Model):
@@ -130,6 +134,7 @@ class CallUserChannelRelationship(models.Model):
) )
is_masked: bool = fields.BooleanField() is_masked: bool = fields.BooleanField()
mask: int = fields.BigIntField() mask: int = fields.BigIntField()
created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True)
@property @property
def mask_name(self) -> str: def mask_name(self) -> str:
+4 -12
View File
@@ -5,9 +5,7 @@ import discord
from payphone.db import models from payphone.db import models
async def get_channel( async def get_channel(channel: discord.TextChannel, for_update: bool = False) -> models.Channel:
channel: discord.TextChannel, for_update: bool = False
) -> models.Channel:
"""Returns a channel from the database, creating or updating it if it already exists. """Returns a channel from the database, creating or updating it if it already exists.
Args: Args:
@@ -18,15 +16,9 @@ async def get_channel(
models.Channel: The channel row. models.Channel: The channel row.
""" """
model = models.Channel row, _ = await models.Channel.update_or_create(
id=channel.id, server=channel.guild.id
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)
channel_name = channel.name.replace("discord", "d").replace("wumpus", "w") channel_name = channel.name.replace("discord", "d").replace("wumpus", "w")
webhook_name = f"Payphone #{channel_name}" webhook_name = f"Payphone #{channel_name}"