diff --git a/alembic/versions/5014a1d75251_merge_freudpoints_and_confession_.py b/alembic/versions/5014a1d75251_merge_freudpoints_and_confession_.py new file mode 100644 index 0000000..00bf25b --- /dev/null +++ b/alembic/versions/5014a1d75251_merge_freudpoints_and_confession_.py @@ -0,0 +1,24 @@ +"""merge freudpoints and confession exposure + +Revision ID: 5014a1d75251 +Revises: 65f0d772d7ba, 7f5cc27e7f61 +Create Date: 2023-10-12 16:13:29.299389 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "5014a1d75251" +down_revision = ("65f0d772d7ba", "7f5cc27e7f61") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/65f0d772d7ba_add_freudpoint_related_things.py b/alembic/versions/65f0d772d7ba_add_freudpoint_related_things.py new file mode 100644 index 0000000..6e9a94f --- /dev/null +++ b/alembic/versions/65f0d772d7ba_add_freudpoint_related_things.py @@ -0,0 +1,87 @@ +"""add FreudPoint related things + +Revision ID: 65f0d772d7ba +Revises: 2adbec7716ff +Create Date: 2023-10-05 16:26:53.915342 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "65f0d772d7ba" +down_revision = "2adbec7716ff" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "profile_statistics", + sa.Column( + "profile_discord_id", + sa.BigInteger, + sa.ForeignKey("profile.discord_id"), + nullable=False, + ), + sa.Column( + "config_guild_id", + sa.BigInteger, + sa.ForeignKey("config.guild_id"), + nullable=False, + ), + sa.Column( + "freudpoints", sa.Integer, nullable=False, default=0, server_default="0" + ), + sa.Column( + "spendable_freudpoints", + sa.Integer, + nullable=False, + default=1, + server_default="1", + ), + ) + + op.create_primary_key( + "pk_profilestat_discordid_guildid", + "profile_statistics", + ["profile_discord_id", "config_guild_id"], + ) + + op.create_check_constraint( + "freudpoints_min_0", "profile_statistics", "freudpoints >= 0" + ) + op.create_check_constraint( + "spendable_freudpoints_min_0", + "profile_statistics", + "spendable_freudpoints >= 0", + ) + + op.add_column( + "config", + sa.Column( + "max_spendable_freudpoints", + sa.Integer, + nullable=False, + default=5, + server_default="5", + ), + ) + + op.create_check_constraint( + "max_spendable_freudpoints_min_0", "config", "max_spendable_freudpoints >= 0" + ) + + +def downgrade() -> None: + op.drop_constraint("max_spendable_freudpoints_min_0", "config", type_="check") + + op.drop_column("config", "max_spendable_freudpoints") + + op.drop_constraint( + "spendable_freudpoints_min_0", "profile_statistics", type_="check" + ) + op.drop_constraint("freudpoints_min_0", "profile_statistics", type_="check") + + op.drop_table("profile_statistics") diff --git a/alembic/versions/7f5cc27e7f61_add_confession_exposure_counter.py b/alembic/versions/7f5cc27e7f61_add_confession_exposure_counter.py new file mode 100644 index 0000000..473f4b8 --- /dev/null +++ b/alembic/versions/7f5cc27e7f61_add_confession_exposure_counter.py @@ -0,0 +1,29 @@ +"""add confession exposure counter + +Revision ID: 7f5cc27e7f61 +Revises: 2adbec7716ff +Create Date: 2023-10-07 21:43:38.674746 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "7f5cc27e7f61" +down_revision = "2adbec7716ff" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "profile", + sa.Column( + "confession_exposed_count", sa.Integer, nullable=False, server_default="0" + ), + ) + + +def downgrade() -> None: + op.drop_column("profile", "confession_exposed_count") diff --git a/alembic/versions/a37a393bc831_move_exposed_count_to_profile_statistics.py b/alembic/versions/a37a393bc831_move_exposed_count_to_profile_statistics.py new file mode 100644 index 0000000..cd939de --- /dev/null +++ b/alembic/versions/a37a393bc831_move_exposed_count_to_profile_statistics.py @@ -0,0 +1,54 @@ +"""move exposed count to profile_statistics + +Revision ID: a37a393bc831 +Revises: 5014a1d75251 +Create Date: 2023-10-12 16:16:04.210967 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a37a393bc831" +down_revision = "5014a1d75251" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "profile_statistics", + sa.Column( + "confession_exposed_count", + sa.Integer, + nullable=False, + default=0, + server_default="0", + ), + ) + + op.execute( + "update profile_statistics set confession_exposed_count=p.confession_exposed_count from profile p where profile_discord_id=p.discord_id" + ) + + op.drop_column("profile", "confession_exposed_count") + + +def downgrade() -> None: + op.add_column( + "profile", + sa.Column( + "confession_exposed_count", + sa.Integer, + nullable=False, + default=0, + server_default="0", + ), + ) + + op.execute( + "update profile set confession_exposed_count=s.confession_exposed_count from profile_statistics s where discord_id=s.profile_discord_id" + ) + + op.drop_column("profile_statistics", "confession_exposed_count") diff --git a/bot/__main__.py b/bot/__main__.py index 3bbef65..84c46b9 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -18,6 +18,9 @@ async def main(): logger.info("Loading extensions...") await bot.instance.load_extensions() + logger.info("Starting tasks...") + await bot.instance.start_tasks() + logger.info("Starting bot...") await bot.instance.start(DISCORD_TOKEN) diff --git a/bot/bot.py b/bot/bot.py index 28c727e..efcb0d6 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,5 +1,6 @@ import asyncio from contextlib import suppress +import importlib.util from typing import Sequence import logging @@ -19,9 +20,11 @@ class Bot(commands.Bot): """Custom discord bot class""" def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger: Logger = None self.discord_logger: GuildAdapter = None - super().__init__(*args, **kwargs) + self.loop = asyncio.get_running_loop() @classmethod async def create(cls) -> "Bot": @@ -51,6 +54,28 @@ async def load_extensions(self) -> None: await self.load_extension(ext) logger.info(f"loaded extension '{ext}'") + async def start_tasks(self) -> None: + """Start all bot-related tasks""" + + from bot.tasks import TASKS + + await self._async_setup_hook() + + for task in TASKS: + await self._start_task(task) + logger.info(f"started task '{task}'") + + async def _start_task(self, name: str): + spec = importlib.util.find_spec(name) + if spec is None: + raise ImportError(f"Task {name} not found") + + lib = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lib) + + setup = getattr(lib, "setup") + await setup(self) + async def add_cog( self, cog: commands.Cog, diff --git a/bot/extensions/fun/confess.py b/bot/extensions/fun/confess.py index e00e922..b0f860a 100644 --- a/bot/extensions/fun/confess.py +++ b/bot/extensions/fun/confess.py @@ -1,4 +1,5 @@ import random +from enum import Enum import discord from discord import ( @@ -13,6 +14,7 @@ from discord.ui import View, Button from models.config import Config +from models.profile_statistics import ProfileStatistics from bot.bot import Bot from bot.decorators import ( @@ -22,30 +24,65 @@ from bot.extensions import ErrorHandledCog -async def send_pending_confession( - confession: str, - confession_channel: TextChannel, - approval_channel: TextChannel, - russian_roulette_user: Member = None, -): - confession_embed = Embed( - colour=Colour.from_rgb(255, 255, 0), - title="🔫 Russian Roulette Confession 🔫" - if russian_roulette_user - else "🥷 Anonymous Confession 🥷", - description=confession, +class ConfessionType(Enum): + NORMAL = (None, "🥷 Anonymous Confession 🥷", "Confession sent") + RUSSIAN = ( + 1 / 6, + "🔫 Russian Roulette Confession 🔫", + "Russian roulette confession sent", ) - - pending_view = PendingApprovalView( - confession=confession_embed, - confession_channel=confession_channel, - russian_roulette_user=russian_roulette_user, + EXTREME = ( + 1 / 2, + "💥 Extreme Roulette Confession 💥", + "Extreme roulette confession sent", ) - pending_view.message = await approval_channel.send( - embed=confession_embed, - view=pending_view, - ) + @property + def chance(self): + return self.value[0] + + @property + def title(self): + return self.value[1] + + @property + def success_msg(self): + return self.value[2] + + +class Confession: + def __init__( + self, + confession: str, + poster: Member, + type_: ConfessionType, + confession_channel: TextChannel, + approval_channel: TextChannel, + ): + self.confession = confession + self.poster = poster + self.type_ = type_ + self.confession_channel = confession_channel + self.approval_channel = approval_channel + + async def send_pending(self): + pending_embed = Embed( + colour=Colour.from_rgb(255, 255, 0), + title=self.type_.title, + description=self.confession, + ) + + pending_view = PendingApprovalView( + confession=pending_embed, + confession_channel=self.confession_channel, + poster=self.poster, + chance=self.type_.chance, + ) + + pending_view.message = await self.approval_channel.send( + embed=pending_embed, + view=pending_view, + ) class PendingApprovalView(View): @@ -53,26 +90,17 @@ def __init__( self, confession: Embed, confession_channel: TextChannel, - russian_roulette_user: Member = None, + poster: Member, + chance: None | float, ): super().__init__(timeout=None) self.confession = confession self.confession_channel = confession_channel - self.russian_roulette_user = russian_roulette_user + self.poster = poster + self.chance = chance @discord.ui.button(label="✓", style=ButtonStyle.green) async def approve(self, ia: Interaction, _btn: Button): - actual_confession = self.confession.copy() - - actual_confession.colour = Colour.random() - - if self.russian_roulette_user is not None and random.random() <= 1 / 6: - actual_confession.add_field( - name="Sent By", value=self.russian_roulette_user.mention - ) - - await self.confession_channel.send(embed=actual_confession) - for item in self.children: item.disabled = True @@ -81,6 +109,17 @@ async def approve(self, ia: Interaction, _btn: Button): await self.message.edit(embed=self.confession, view=self) await ia.response.defer() + actual_confession = self.confession.copy() + + actual_confession.colour = Colour.random() + + if self.chance is not None and random.random() <= self.chance: + await ProfileStatistics.increment_exposed_count(self.poster.id, ia.guild_id) + + actual_confession.add_field(name="Sent By", value=self.poster.mention) + + await self.confession_channel.send(embed=actual_confession) + @discord.ui.button(label="⨯", style=ButtonStyle.red) async def reject(self, ia: Interaction, _btn: Button): for item in self.children: @@ -93,13 +132,12 @@ async def reject(self, ia: Interaction, _btn: Button): class Confess(ErrorHandledCog): - @app_commands.command(name="confess", description="send an anonymous confession") - @app_commands.describe(confession="The confession you want to post") - @app_commands.guild_only() - @check_has_config_option("confession_approval_channel") - @check_has_config_option("confession_channel") - @check_user_is_verified() - async def confess(self, ia: Interaction, confession: str): + confess_group = app_commands.Group( + name="confess", description="Confession related commands", guild_only=True + ) + + @staticmethod + async def confess_inner(ia: Interaction, confession: str, type_: ConfessionType): config = await Config.get(ia.guild_id) approval_channel = discord.utils.get( ia.guild.channels, id=config.confession_approval_channel @@ -108,38 +146,51 @@ async def confess(self, ia: Interaction, confession: str): ia.guild.channels, id=config.confession_channel ) - await send_pending_confession(confession, confession_channel, approval_channel) + confession_wrapper = Confession( + confession=confession, + poster=ia.user, + type_=type_, + confession_channel=confession_channel, + approval_channel=approval_channel, + ) - await ia.response.send_message(content="Confession sent", ephemeral=True) + await confession_wrapper.send_pending() - @app_commands.command( - name="russianconfess", - description="send a confession with a 1 in 6 chance of not being anonymous", + await ia.response.send_message( + content=confession_wrapper.type_.success_msg, ephemeral=True + ) + + @confess_group.command( + name="normal", description="send a normal, anonymous confession" ) @app_commands.describe(confession="The confession you want to post") - @app_commands.guild_only() @check_has_config_option("confession_approval_channel") @check_has_config_option("confession_channel") @check_user_is_verified() - async def russian_confess(self, ia: Interaction, confession: str): - config = await Config.get(ia.guild_id) - approval_channel = discord.utils.get( - ia.guild.channels, id=config.confession_approval_channel - ) - confession_channel = discord.utils.get( - ia.guild.channels, id=config.confession_channel - ) + async def normal_confess(self, ia: Interaction, confession: str): + await self.confess_inner(ia, confession, ConfessionType.NORMAL) - await send_pending_confession( - confession, - confession_channel, - approval_channel, - russian_roulette_user=ia.user, - ) + @confess_group.command( + name="russian", + description="send a russian roulette confession with a 1 in 6 chance of not being anonymous", + ) + @app_commands.describe(confession="The confession you want to post") + @check_has_config_option("confession_approval_channel") + @check_has_config_option("confession_channel") + @check_user_is_verified() + async def russian_confess(self, ia: Interaction, confession: str): + await self.confess_inner(ia, confession, ConfessionType.RUSSIAN) - await ia.response.send_message( - content="Russian roulette confession sent", ephemeral=True - ) + @confess_group.command( + name="extreme", + description="send an extreme roulette confession with a 1 in 2 chance of not being anonymous", + ) + @app_commands.describe(confession="The confession you want to post") + @check_has_config_option("confession_approval_channel") + @check_has_config_option("confession_channel") + @check_user_is_verified() + async def russian_confess(self, ia: Interaction, confession: str): + await self.confess_inner(ia, confession, ConfessionType.EXTREME) async def setup(bot: Bot): diff --git a/bot/extensions/fun/stats/__init__.py b/bot/extensions/fun/stats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/extensions/fun/stats/freudpoints.py b/bot/extensions/fun/stats/freudpoints.py new file mode 100644 index 0000000..84d4a20 --- /dev/null +++ b/bot/extensions/fun/stats/freudpoints.py @@ -0,0 +1,70 @@ +from typing import Optional +import asyncio + +from discord import ( + app_commands, + Interaction, + Member, +) + +from bot.bot import Bot +from bot.decorators import ( + check_user_is_verified, +) +from bot.extensions import ErrorHandledCog +from models.profile_statistics import ProfileStatistics + + +class FreudPoints(ErrorHandledCog): + freudpoint_group = app_commands.Group( + name="freudpoint", description="FreudPoint awards and leaderboards" + ) + + @freudpoint_group.command(name="award", description="Award FreudPoints to somebody") + @app_commands.describe(user="The user to award points to") + @app_commands.describe(amount="The amount of points to award") + @app_commands.guild_only() + @check_user_is_verified() + async def award_freudpoints( + self, ia: Interaction, user: Member, amount: Optional[int] = 1 + ): + if ia.user.id == user.id: + return await ia.response.send_message( + "Nice try, but you can't award FreudPoints to yourself", + ephemeral=True, + ) + + if amount <= 0: + return await ia.response.send_message( + "You have to award at least 1 FreudPoint", + ephemeral=True, + ) + + awarder_stats = await ProfileStatistics.get(ia.user.id, ia.guild_id) + + if awarder_stats.spendable_freudpoints < amount: + return await ia.response.send_message( + "You don't have enough FreudPoints available to give out!\nPlease wait a few days until you have enough", + ephemeral=True, + ) + + awardee_stats = await ProfileStatistics.get(user.id, ia.guild_id) + + awarder_stats.spendable_freudpoints -= amount + awardee_stats.freudpoints += amount + + await asyncio.gather( + *[ + awarder_stats.save(), + awardee_stats.save(), + ] + ) + + return await ia.response.send_message( + f"{user.display_name} has been awarded {amount} FreudPoint(s)!", + ephemeral=True, + ) + + +async def setup(bot: Bot): + await bot.add_cog(FreudPoints(bot)) diff --git a/bot/extensions/fun/stats/overview.py b/bot/extensions/fun/stats/overview.py new file mode 100644 index 0000000..3ee7b8d --- /dev/null +++ b/bot/extensions/fun/stats/overview.py @@ -0,0 +1,182 @@ +import discord +from discord import app_commands, Interaction, Member, Embed, SelectOption, Message +from discord.ui import View, Select + +from models.profile import Profile + +from bot.bot import Bot +from bot.decorators import ( + check_user_is_verified, +) +from bot.extensions import ErrorHandledCog +from models.profile_statistics import ProfileStatistics + + +class LeaderboardDropdown(Select["Leaderboard"]): + def __init__(self): + options = [ + SelectOption( + label="FreudPoint Rank", + value="freudpoint", + default=True, + ), + SelectOption( + label="Confession Exposures", + value="exposed", + ), + ] + + super().__init__(options=options) + + async def callback(self, ia: Interaction): + if ia.user.id != self.view.owner.id: + return await ia.response.send_message( + "That message doesn't belong to you.\nRun '/freudstat leaderboard' to get a message you can interact with", + ephemeral=True, + ) + + await ia.response.defer() + + value = self.values[0] + if value == "freudpoint": + self.set_default_option(0) + leaderboard = await self.make_freudpoint_leaderboard(ia) + elif value == "exposed": + self.set_default_option(1) + leaderboard = await self.make_exposed_leaderboard(ia) + else: + raise ValueError() + + await ia.edit_original_response(embed=leaderboard, view=self.view) + + def set_default_option(self, idx: int): + for opt in self.options: + opt.default = False + + self.options[idx].default = True + + @staticmethod + async def make_freudpoint_leaderboard(ia: Interaction) -> Embed: + top_10 = await ProfileStatistics.get_freudpoint_top_10(ia.guild_id) + + top_10 = [ + f"#{i + 1} - <@{p.profile_discord_id}> ({p.freudpoints})" + for i, p in enumerate(top_10) + ] + + return Embed( + title="Members with the most FreudPoints", description="\n".join(top_10) + ) + + @staticmethod + async def make_exposed_leaderboard(ia: Interaction) -> Embed: + top_10 = await ProfileStatistics.get_exposed_top_10(ia.guild_id) + + top_10 = [ + f"#{i + 1} - <@{p.profile_discord_id}> ({p.confession_exposed_count})" + for i, p in enumerate(top_10) + ] + + return Embed(title="Most exposed members", description="\n".join(top_10)) + + +class Leaderboard(View): + def __init__(self, owner: Member): + super().__init__() + + self.owner: Member = owner + + self.add_item(LeaderboardDropdown()) + + +class FreudStatOverview(ErrorHandledCog): + freudstat_group = app_commands.Group( + name="freudstat", description="FreudStats personal and global statistics" + ) + + @freudstat_group.command( + name="me", + description="Get an overview of your personal FreudStats profile", + ) + @app_commands.guild_only() + @check_user_is_verified() + async def show_me(self, ia: Interaction): + user = await Profile.find_by_discord_id(ia.user.id) + stats = await ProfileStatistics.get(ia.user.id, ia.guild_id) + + rank = await user.get_freudpoint_rank(ia.guild_id) + + profile_embed = ( + Embed(title=f"{ia.user.display_name}s Profile", colour=ia.user.colour) + .set_thumbnail(url=ia.user.display_avatar.url) + .add_field( + name="FreudPoints", + value=f"#{rank + 1} ({stats.freudpoints} FP)", + inline=True, + ) + .add_field( + name="Spendable FreudPoints", + value=stats.spendable_freudpoints, + inline=True, + ) + .add_field( + name="Confession Exposures", + value=stats.confession_exposed_count, + inline=False, + ) + ) + + return await ia.response.send_message(embed=profile_embed) + + @freudstat_group.command( + name="profile", description="Get an overview of somebodies profile" + ) + @app_commands.describe(user="The user whose profile you want to see") + @app_commands.guild_only() + @check_user_is_verified() + async def show_profile(self, ia: Interaction, user: Member): + db_user = await Profile.find_by_discord_id(user.id) + + if db_user is None: + return await ia.response.send_message( + f"{user.display_name} is not verified and doesn't have a profile" + ) + + stats = await ProfileStatistics.get(user.id, ia.guild_id) + rank = await db_user.get_freudpoint_rank(ia.guild_id) + + profile_embed = ( + Embed(title=f"{user.display_name}s Profile", colour=user.colour) + .set_thumbnail(url=user.display_avatar.url) + .add_field( + name="FreudPoints", + value=f"#{rank + 1} ({stats.freudpoints} FP)", + inline=True, + ) + .add_field( + name="Confession Exposures", + value=stats.confession_exposed_count, + inline=False, + ) + ) + + return await ia.response.send_message(embed=profile_embed) + + @freudstat_group.command( + name="leaderboard", + description="See a leaderboard of members with the most FreudPoints", + ) + @app_commands.guild_only() + @check_user_is_verified() + async def show_leaderboard(self, ia: Interaction): + leaderboard = await LeaderboardDropdown.make_freudpoint_leaderboard(ia) + dropwdown = Leaderboard(ia.user) + + await ia.response.send_message( + embed=leaderboard, + view=dropwdown, + ) + + +async def setup(bot: Bot): + await bot.add_cog(FreudStatOverview(bot)) diff --git a/bot/extensions/moderation/verification.py b/bot/extensions/moderation/verification.py index 004842a..4549f51 100644 --- a/bot/extensions/moderation/verification.py +++ b/bot/extensions/moderation/verification.py @@ -14,10 +14,10 @@ from bot.bot import Bot from bot.decorators import ( check_has_config_option, - only_in_channel, ) from bot.exceptions import MissingConfig from bot.extensions import ErrorHandledCog +from models.profile_statistics import ProfileStatistics EMAIL_REGEX = re.compile(r"^[^\s@]+@ugent\.be$") @@ -233,6 +233,10 @@ async def on_submit(self, ia: Interaction): profile.confirmation_code = None await profile.save() + await ProfileStatistics.create( + profile_discord_id=ia.user.id, config_guild_id=self.guild.id + ) + self.bot.discord_logger.info( f"{ia.user.mention} verified succesfully with email '{profile.email}'", guild=self.guild, @@ -350,6 +354,10 @@ async def handle_member_join(self, member: Member): discord.utils.get(guild.roles, id=guild_config.verified_role) ) + await ProfileStatistics.create( + profile_discord_id=member.id, config_guild_id=guild.id + ) + self.bot.discord_logger.info( f"{member.mention} has been automatically verified, their email is {profile.email}", guild=guild, diff --git a/bot/tasks/__init__.py b/bot/tasks/__init__.py new file mode 100644 index 0000000..8fea8c1 --- /dev/null +++ b/bot/tasks/__init__.py @@ -0,0 +1,27 @@ +import pkgutil +import importlib +import inspect +from typing import Iterator, NoReturn + +from bot import tasks + + +def walk_tasks() -> Iterator[str]: + """Yield all task names from the bot.tasks package""" + + def on_error(name: str) -> NoReturn: + raise ImportError(name=name) + + for module in pkgutil.walk_packages( + tasks.__path__, f"{tasks.__name__}.", onerror=on_error + ): + if module.ispkg: + imported = importlib.import_module(module.name) + if not inspect.isfunction(getattr(imported, "setup", None)): + # Skip tasks without a setup function + continue + + yield module.name + + +TASKS = frozenset(walk_tasks()) diff --git a/bot/tasks/freudpoint_increment.py b/bot/tasks/freudpoint_increment.py new file mode 100644 index 0000000..8346a97 --- /dev/null +++ b/bot/tasks/freudpoint_increment.py @@ -0,0 +1,32 @@ +import asyncio +from datetime import datetime, timezone, timedelta +import logging +from bot.bot import Bot + +from models.profile_statistics import ProfileStatistics + + +logger = logging.getLogger("freudpoint_increment") + + +async def freudpoint_increment(): + while True: + now = datetime.now().astimezone(timezone.utc).replace(microsecond=0) + next_midnight = ( + (now + timedelta(days=1)) + .replace(hour=0, minute=0, second=0, microsecond=0) + .astimezone(timezone.utc) + ) + + delay = (next_midnight - now).total_seconds() + + logger.info(f"waiting {delay}s until next midnight") + await asyncio.sleep(delay) + + logger.info("incrementing spendable freudpoints...") + await ProfileStatistics.increment_spendable_freudpoints() + logger.info("done") + + +async def setup(bot: Bot): + bot.loop.create_task(freudpoint_increment()) diff --git a/create_stats.sql b/create_stats.sql new file mode 100644 index 0000000..d8623f9 --- /dev/null +++ b/create_stats.sql @@ -0,0 +1,9 @@ +insert into profile_statistics + (profile_discord_id, config_guild_id) +select + profile.discord_id, config.guild_id +from + profile, config +where + profile.email is not null and profile.confirmation_code is null +on conflict do nothing; diff --git a/models/config.py b/models/config.py index 17c056a..799269f 100644 --- a/models/config.py +++ b/models/config.py @@ -2,8 +2,9 @@ from typing import Optional from sqlalchemy import Column, BigInteger, Integer, Text, select +from sqlalchemy.engine import Result from sqlalchemy.schema import FetchedValue -from sqlalchemy.orm import Query +from sqlalchemy.orm import validates from discord import Guild @@ -32,16 +33,24 @@ class Config(Base, Model): invalid_code_message = Column(Text, FetchedValue(), nullable=False) already_verified_message = Column(Text, FetchedValue(), nullable=False) welcome_message = Column(Text, FetchedValue(), nullable=False) + max_spendable_freudpoints = Column(Integer, nullable=False) def __repr__(self) -> str: return f"Config(guild_id={self.guild_id}, verified_role={self.verified_role}, admin_role={self.admin_role}, logging_channel={self.logging_channel}, confession_approval_channel={self.confession_approval_channel}, confession_channel={self.confession_channel})" + @validates("max_spendable_freudpoints") + def validate_max_spendable_fp_positive(self, key, value): + if value < 0: + raise ValueError(f"maximum spendable FreudPoints must be at least 0") + + return value + @classmethod async def get(cls, guild_id: int) -> Optional["Config"]: """Find a config given its guild ID""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.guild_id == guild_id) ) @@ -56,7 +65,7 @@ async def get_or_create(cls, guild: Guild) -> "Config": """Find a config given its guild ID, or create an empty config if it does not exist""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.guild_id == guild.id) ) diff --git a/models/course.py b/models/course.py index 1e631cc..0ff4be7 100644 --- a/models/course.py +++ b/models/course.py @@ -2,7 +2,7 @@ from typing import Optional from sqlalchemy import Column, Text, select -from sqlalchemy.orm import Query +from sqlalchemy.engine import Result from models import Base, Model, session_factory from models.enrollment import Enrollment @@ -26,7 +26,7 @@ async def find_by_name(cls, name: str) -> Optional["Course"]: """Find a course given its name""" async with session_factory() as session: - result: Query = await session.execute(select(cls).where(cls.name == name)) + result: Result = await session.execute(select(cls).where(cls.name == name)) r = result.first() if r is None: @@ -39,7 +39,7 @@ async def find_by_code(cls, code: str) -> Optional["Course"]: """Find a course given its code""" async with session_factory() as session: - result: Query = await session.execute(select(cls).where(cls.code == code)) + result: Result = await session.execute(select(cls).where(cls.code == code)) r = result.first() if r is None: @@ -52,7 +52,7 @@ async def get_all_names(cls) -> list[str]: """Get the names of all available courses""" async with session_factory() as session: - result: Query = await session.execute(select(cls.name)) + result: Result = await session.execute(select(cls.name)) return result.scalars().all() diff --git a/models/enrollment.py b/models/enrollment.py index e2089b2..af9f592 100644 --- a/models/enrollment.py +++ b/models/enrollment.py @@ -1,7 +1,7 @@ from typing import Optional from sqlalchemy import Column, Text, select -from sqlalchemy.orm import Query +from sqlalchemy.engine import Result from models import Base, Model, session_factory @@ -22,7 +22,7 @@ async def find( """Find a specific enrollment""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls) .where(cls.profile_discord_id == profile_discord_id) .where(cls.course_code == course_code) @@ -39,7 +39,7 @@ async def find_for_profile(cls, profile_id: str) -> list["Enrollment"]: """Find all enrollments for a given profile""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.profile_discord_id == profile_id) ) @@ -50,7 +50,7 @@ async def find_for_course(cls, course_code: str) -> list["Enrollment"]: """Find all enrollments for a given course""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.course_code == course_code) ) diff --git a/models/lecture.py b/models/lecture.py index 7401e63..08482a3 100644 --- a/models/lecture.py +++ b/models/lecture.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Text, Integer, select -from sqlalchemy.orm import Query +from sqlalchemy.engine import Result from models import Base, Model, session_factory @@ -55,7 +55,7 @@ async def find_for_course(cls, course_code: str) -> list["Lecture"]: """Find all lectures for a given course""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.course_code == course_code) ) diff --git a/models/profile.py b/models/profile.py index 0e15e3a..616543a 100644 --- a/models/profile.py +++ b/models/profile.py @@ -2,9 +2,10 @@ from discord import Guild from sqlalchemy import Column, Text, BigInteger, select -from sqlalchemy.orm import Query +from sqlalchemy.engine import Result from models import Base, Model, session_factory +from models.profile_statistics import ProfileStatistics class Profile(Base, Model): @@ -17,12 +18,22 @@ class Profile(Base, Model): def __repr__(self) -> str: return f"Profile(discord_id={self.discord_id}, email={self.email}, confirmation_code={self.confirmation_code})" + def __eq__(self, other: object) -> bool: + if not isinstance(other, Profile): + return NotImplemented + + return ( + self.discord_id == other.discord_id + and self.email == other.email + and self.confirmation_code == other.confirmation_code + ) + @classmethod async def find_by_discord_id(cls, discord_id: int) -> Optional["Profile"]: """Find a profile given its discord_id""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where(cls.discord_id == discord_id) ) @@ -37,7 +48,9 @@ async def find_by_email(cls, email: str) -> Optional["Profile"]: """Find a profile given its email""" async with session_factory() as session: - result: Query = await session.execute(select(cls).where(cls.email == email)) + result: Result = await session.execute( + select(cls).where(cls.email == email) + ) r = result.first() if r is None: @@ -50,7 +63,7 @@ async def find_verified_in_guild(cls, guild: Guild) -> list["Profile"]: """Find all profiles in a specific guild that are verified""" async with session_factory() as session: - result: Query = await session.execute( + result: Result = await session.execute( select(cls).where( cls.confirmation_code.is_(None), cls.email.is_not(None) ) @@ -62,3 +75,18 @@ async def find_verified_in_guild(cls, guild: Guild) -> list["Profile"]: lambda p: guild.get_member(p.discord_id) is not None, profiles ) return list(profiles) + + async def get_freudpoint_rank(self, guild_id: int) -> int: + """Get a profiles FreudPoint score rank in a given guild""" + + async with session_factory() as session: + query: Result = await session.execute( + select(Profile) + .join(ProfileStatistics) + .where(ProfileStatistics.config_guild_id == guild_id) + .order_by(ProfileStatistics.freudpoints.desc()) + ) + + profiles: list["Profile"] = query.scalars().all() + + return profiles.index(self) diff --git a/models/profile_statistics.py b/models/profile_statistics.py new file mode 100644 index 0000000..21addb8 --- /dev/null +++ b/models/profile_statistics.py @@ -0,0 +1,150 @@ +from typing import Optional +from sqlalchemy import ( + Column, + BigInteger, + ForeignKey, + select, + update, + Integer, + func, +) +from sqlalchemy.engine import Result +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import validates, relationship +from sqlalchemy.schema import FetchedValue + +from models import Base, Model, session_factory +from models.config import Config + + +class ProfileStatistics(Base, Model): + __tablename__ = "profile_statistics" + + profile_discord_id = Column( + BigInteger, ForeignKey("profile.discord_id"), primary_key=True + ) + config_guild_id = Column( + BigInteger, ForeignKey("config.guild_id"), primary_key=True + ) + + freudpoints = Column(Integer, FetchedValue(), nullable=False) + spendable_freudpoints = Column(Integer, FetchedValue(), nullable=False) + confession_exposed_count = Column(Integer, FetchedValue(), nullable=False) + + profile = relationship("Profile", foreign_keys=[profile_discord_id]) + config = relationship("Config", foreign_keys=[config_guild_id]) + + @validates("freudpoints") + def validate_fp_positive(self, key, value): + if value < 0: + raise ValueError(f"FreudPoints must be at least 0") + + return value + + @validates("spendable_freudpoints") + def validate_spendable_fp_positive(self, key, value): + if value < 0: + raise ValueError(f"spendable FreudPoints must be at least 0") + + return value + + @classmethod + async def get(cls, discord_id: int, guild_id: int) -> "ProfileStatistics": + """ + Find statistics given a profile and guild + + Creates a new row if there isn't one already + """ + + async with session_factory() as session: + result: Result = await session.execute( + select(cls).where( + cls.profile_discord_id == discord_id, + cls.config_guild_id == guild_id, + ) + ) + + r = result.first() + if r is None: + new_stats = cls(profile_discord_id=discord_id, config_guild_id=guild_id) + session.add(new_stats) + await session.commit() + + return new_stats + + return r[0] + + @classmethod + async def increment_spendable_freudpoints(cls): + """Increment the spendable freudpoints for each profile by 1 up to the max""" + + async with session_factory() as session: + max_spendable_subquery = ( + select(Config.max_spendable_freudpoints) + .where(Config.guild_id == cls.config_guild_id) + .limit(1) + .scalar_subquery() + ) + + await session.execute( + update(cls).values( + spendable_freudpoints=func.least( + cls.spendable_freudpoints + 1, max_spendable_subquery + ) + ) + ) + + await session.commit() + + @classmethod + async def get_freudpoint_top_10(cls, guild_id: int) -> list["ProfileStatistics"]: + """Get a top 10 of the members in the given guild with the most freudpoints""" + + async with session_factory() as session: + result: Result = await session.execute( + select(cls) + .where(cls.config_guild_id == guild_id) + .order_by(cls.freudpoints.desc()) + .limit(10) + ) + + return result.scalars().all() + + @classmethod + async def increment_exposed_count(cls, discord_id: int, guild_id: int): + """Increment the confession exposed count for a profile in a given server""" + + async with session_factory() as session: + result: Result = await session.execute( + update(cls) + .where( + cls.profile_discord_id == discord_id, + cls.config_guild_id == guild_id, + ) + .values(confession_exposed_count=cls.confession_exposed_count + 1) + .returning(cls) + ) + + if len(result.scalars().all()) == 0: + new_stats = cls( + profile_discord_id=discord_id, + config_guild_id=guild_id, + confession_exposed_count=1, + ) + session.add(new_stats) + + await session.commit() + + @classmethod + async def get_exposed_top_10(cls, guild_id: int) -> list["ProfileStatistics"]: + """Get a top 10 of the most exposed users for the given guild""" + + async with session_factory() as session: + result: Result = await session.execute( + select(cls) + .where(cls.config_guild_id == guild_id) + .order_by(cls.confession_exposed_count.desc()) + .limit(10) + ) + + return result.scalars().all() diff --git a/pyproject.toml b/pyproject.toml index 5848e31..6f9fd7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "freud_bot" -version = "3.2.2" +version = "3.4.1" description = "UGent discord bot" authors = ["Tibo Ulens "]