diff --git a/poetry.lock b/poetry.lock index e358d29..db15b2a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -679,6 +679,17 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "emojis" +version = "0.7.0" +description = "Emojis for Python" +optional = false +python-versions = "*" +files = [ + {file = "emojis-0.7.0-py3-none-any.whl", hash = "sha256:a777926d8ab0bfdd51250e899a3b3524a1e969275ac8e747b4a05578fa597367"}, + {file = "emojis-0.7.0.tar.gz", hash = "sha256:5f437674da878170239af9a8196e50240b5922d6797124928574008442196b52"}, +] + [[package]] name = "filelock" version = "3.15.4" @@ -2425,4 +2436,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.12,<4" -content-hash = "bbac8f5b09eb56eaa68724e6abfabfd587e57f38d101c5a421ee80c62c2e04dc" +content-hash = "ff3627917ae2897246db3ffc167ff5dcb45f550119ee7116f453fd5091bac646" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 476937d..33c1fd2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,14 +40,15 @@ enum CaseType { // Docs: https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-models // Docs: https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-attributes model Guild { - guild_id BigInt @id - guild_joined_at DateTime? @default(now()) - cases Case[] - snippets Snippet[] - notes Note[] - reminders Reminder[] - guild_config GuildConfig[] - Starboard Starboard[] + guild_id BigInt @id + guild_joined_at DateTime? @default(now()) + cases Case[] + snippets Snippet[] + notes Note[] + reminders Reminder[] + guild_config GuildConfig[] + Starboard Starboard[] + StarboardMessage StarboardMessage[] @@unique([guild_id]) @@index([guild_id]) @@ -154,3 +155,19 @@ model Starboard { @@unique([guild_id]) @@index([guild_id]) } + +model StarboardMessage { + message_id BigInt @id + message_content String + message_created_at DateTime @default(now()) + message_expires_at DateTime + message_channel_id BigInt + message_user_id BigInt + message_guild_id BigInt + star_count Int @default(0) + starboard_message_id BigInt + guild Guild @relation(fields: [message_guild_id], references: [guild_id]) + + @@unique([message_id, message_guild_id]) + @@index([message_id, message_guild_id]) +} diff --git a/pyproject.toml b/pyproject.toml index 95a9223..98cb797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ sentry-sdk = {extras = ["httpx", "loguru"], version = "^2.7.0"} types-aiofiles = "^24.1.0.20240626" types-psutil = "^6.0.0.20240621" typing-extensions = "^4.12.2" +emojis = "^0.7.0" [tool.poetry.group.docs.dependencies] mkdocs-material = "^9.5.30" diff --git a/tux/cogs/starboard/starboard.py b/tux/cogs/starboard/starboard.py new file mode 100644 index 0000000..1af4444 --- /dev/null +++ b/tux/cogs/starboard/starboard.py @@ -0,0 +1,216 @@ +from datetime import UTC, datetime, timedelta + +import discord +import emojis +from discord.ext import commands +from loguru import logger + +from tux.bot import Tux +from tux.database.controllers.starboard import StarboardController, StarboardMessageController +from tux.utils import checks + + +class Starboard(commands.Cog): + def __init__(self, bot: Tux) -> None: + self.bot = bot + self.starboard_controller = StarboardController() + self.starboard_message_controller = StarboardMessageController() + + @commands.hybrid_group( + name="starboard", + usage="starboard ", + description="Configure the starboard for this server", + ) + @commands.guild_only() + @checks.has_pl(7) # server owner only + async def starboard(self, ctx: commands.Context[Tux]) -> None: + if ctx.invoked_subcommand is None: + await ctx.send_help("starboard") + + @starboard.command( + name="setup", + aliases=["s"], + usage="starboard setup ", + description="Configure the starboard for this server", + ) + @commands.has_permissions(manage_guild=True) + async def configure_starboard( + self, + ctx: commands.Context[Tux], + channel: discord.TextChannel, + emoji: str, + threshold: int, + ) -> None: + """ + Configure the starboard for this server. + + Parameters + ---------- + channel: discord.TextChannel + The channel to configure the starboard for + emoji: str + The emoji to use for the starboard + threshold: int + The threshold for the starboard + """ + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + try: + if not emojis.count(emoji, unique=True) or emojis.count(emoji, unique=True) > 1: # type: ignore + await ctx.send("Invalid emoji. Please use a single default Discord emoji.") + return + + if threshold < 1: + await ctx.send("Threshold must be at least 1.") + return + + if not channel.permissions_for(ctx.guild.me).send_messages: + await ctx.send(f"I don't have permission to send messages in {channel.mention}.") + return + + await self.starboard_controller.create_or_update_starboard(ctx.guild.id, channel.id, emoji, threshold) + await ctx.send( + f"Starboard configured successfully. Channel: {channel.mention}, Emoji: {emoji}, Threshold: {threshold}", + ) + except Exception as e: + logger.error(f"Error configuring starboard: {e!s}") + await ctx.send(f"An error occurred while configuring the starboard: {e!s}") + + @starboard.command( + name="remove", + aliases=["r"], + usage="starboard remove", + description="Remove the starboard configuration for this server", + ) + @commands.has_permissions(manage_guild=True) + async def remove_starboard(self, ctx: commands.Context[Tux]) -> None: + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + try: + result = await self.starboard_controller.delete_starboard_by_guild_id(ctx.guild.id) + if result: + await ctx.send("Starboard configuration removed successfully.") + else: + await ctx.send("No starboard configuration found for this server.") + except Exception as e: + logger.error(f"Error removing starboard configuration: {e!s}") + await ctx.send(f"An error occurred while removing the starboard configuration: {e!s}") + + @commands.Cog.listener("on_reaction_add") + async def starboard_check(self, reaction: discord.Reaction, user: discord.User) -> None: + if not reaction.message.guild: + return + + try: + starboard = await self.starboard_controller.get_starboard_by_guild_id(reaction.message.guild.id) + if not starboard: + return + + if str(reaction.emoji) != starboard.starboard_emoji: + logger.debug( + f"Reaction emoji {reaction.emoji} does not match starboard emoji {starboard.starboard_emoji}", + ) + return + + # # Check if the user is not the author of the message + # if user.id == reaction.message.author.id: + # logger.debug(f"User {user.id} tried to star their own message") + # return + + reaction_count = sum( + r.count for r in reaction.message.reactions if str(r.emoji) == starboard.starboard_emoji + ) + + if reaction_count >= starboard.starboard_threshold: + starboard_channel = reaction.message.guild.get_channel(starboard.starboard_channel_id) + logger.info(f"Starboard channel: {starboard_channel}") + + if not isinstance(starboard_channel, discord.TextChannel): + logger.error( + f"Starboard channel {starboard.starboard_channel_id} not found or is not a text channel", + ) + return + + await self.create_or_update_starboard_message(starboard_channel, reaction.message, reaction_count) + except Exception as e: + logger.error(f"Error in starboard_check: {e!s}") + + async def get_existing_starboard_message( + self, + starboard_channel: discord.TextChannel, + original_message: discord.Message, + ) -> discord.Message | None: + assert original_message.guild + try: + starboard_message = await self.starboard_message_controller.get_starboard_message_by_id( + original_message.id, + original_message.guild.id, + ) + logger.info(f"Starboard message: {starboard_message}") + if starboard_message: + return await starboard_channel.fetch_message(starboard_message.starboard_message_id) + except Exception as e: + logger.error(f"Error while fetching starboard message: {e!s}") + + return None + + async def create_or_update_starboard_message( + self, + starboard_channel: discord.TextChannel, + original_message: discord.Message, + reaction_count: int, + ) -> None: + if not original_message.guild: + logger.error("Original message has no guild") + return + + try: + starboard = await self.starboard_controller.get_starboard_by_guild_id(original_message.guild.id) + if not starboard: + logger.error(f"No starboard configuration found for guild {original_message.guild.id}") + return + + embed = discord.Embed( + description=original_message.content, + color=discord.Color.gold(), + timestamp=original_message.created_at, + ) + embed.set_author( + name=original_message.author.display_name, + icon_url=original_message.author.avatar.url if original_message.author.avatar else None, + ) + embed.add_field(name="Source", value=f"[Jump to message]({original_message.jump_url})") + embed.set_footer(text=f"Star count: {reaction_count} {starboard.starboard_emoji}") + + if original_message.attachments: + embed.set_image(url=original_message.attachments[0].url) + + starboard_message = await self.get_existing_starboard_message(starboard_channel, original_message) + + if starboard_message: + await starboard_message.edit(embed=embed) + else: + starboard_message = await starboard_channel.send(embed=embed) + + # Create or update the starboard message entry in the database + await self.starboard_message_controller.create_or_update_starboard_message( + message_id=original_message.id, + message_content=original_message.content, + message_expires_at=datetime.now(UTC) + timedelta(days=30), + message_channel_id=original_message.channel.id, + message_user_id=original_message.author.id, + message_guild_id=original_message.guild.id, + star_count=reaction_count, + starboard_message_id=starboard_message.id, + ) + + except Exception as e: + logger.error(f"Error while creating or updating starboard message: {e!s}") + + +async def setup(bot: Tux) -> None: + await bot.add_cog(Starboard(bot)) diff --git a/tux/cogs/starboard/test.py b/tux/cogs/starboard/test.py deleted file mode 100644 index 8ba98e0..0000000 --- a/tux/cogs/starboard/test.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import cast - -import discord -from discord.ext import commands - -from tux.bot import Tux -from tux.ui.starboard.image_gen import generate_discord_message_image - - -class Starboard(commands.Cog): - def __init__(self, bot: Tux) -> None: - self.bot = bot - - @commands.hybrid_command( - name="test", - usage="test", - ) - async def ping(self, ctx: commands.Context[Tux], *, message: str) -> None: - # nickname: str, pfp_url: str, role_color: str, message_content: str, image_attachment_url: str | None = None - - member = cast(discord.Member, ctx.author) - - nickname = member.display_name - pfp_url = member.display_avatar.url - - role_color = next( - (f"#{role.color.value:06X}" for role in reversed(member.roles) if role.color != discord.Color.default()), - "#FFFFFF", - ) - message_content = message - image_attachment_url = None - - image = generate_discord_message_image(nickname, pfp_url, role_color, message_content, image_attachment_url) - - image.save("image.png") - await ctx.send(file=discord.File("image.png")) - - -async def setup(bot: Tux) -> None: - await bot.add_cog(Starboard(bot)) diff --git a/tux/database/controllers/__init__.py b/tux/database/controllers/__init__.py index 3ae8ff1..7a23c08 100644 --- a/tux/database/controllers/__init__.py +++ b/tux/database/controllers/__init__.py @@ -4,7 +4,7 @@ from .guild_config import GuildConfigController from .note import NoteController from .reminder import ReminderController from .snippet import SnippetController -from .starboard import StarboardController +from .starboard import StarboardController, StarboardMessageController class DatabaseController: @@ -16,3 +16,4 @@ class DatabaseController: self.guild = GuildController() self.guild_config = GuildConfigController() self.starboard = StarboardController() + self.starboard_message = StarboardMessageController() diff --git a/tux/database/controllers/starboard.py b/tux/database/controllers/starboard.py index 4a93a94..cebffce 100644 --- a/tux/database/controllers/starboard.py +++ b/tux/database/controllers/starboard.py @@ -1,4 +1,6 @@ -from prisma.models import Guild, Starboard +from datetime import datetime + +from prisma.models import Guild, Starboard, StarboardMessage from tux.database.client import db @@ -47,3 +49,78 @@ class StarboardController: async def delete_starboard_by_guild_id(self, guild_id: int) -> Starboard | None: return await self.table.delete(where={"guild_id": guild_id}) + + +class StarboardMessageController: + def __init__(self): + self.table = db.starboardmessage + self.guild_table = db.guild + + async def ensure_guild_exists(self, guild_id: int) -> Guild | None: + guild = await self.guild_table.find_unique(where={"guild_id": guild_id}) + if guild is None: + return await self.guild_table.create(data={"guild_id": guild_id}) + return guild + + async def get_starboard_message(self, message_id: int, guild_id: int) -> StarboardMessage | None: + return await self.table.find_unique( + where={"message_id_message_guild_id": {"message_id": message_id, "message_guild_id": guild_id}}, + ) + + async def create_or_update_starboard_message( + self, + message_id: int, + message_content: str, + message_expires_at: datetime, + message_channel_id: int, + message_user_id: int, + message_guild_id: int, + star_count: int, + starboard_message_id: int, + ) -> StarboardMessage: + await self.ensure_guild_exists(message_guild_id) + + return await self.table.upsert( + where={"message_id_message_guild_id": {"message_id": message_id, "message_guild_id": message_guild_id}}, + data={ + "create": { + "message_id": message_id, + "message_content": message_content, + "message_expires_at": message_expires_at, + "message_channel_id": message_channel_id, + "message_user_id": message_user_id, + "message_guild_id": message_guild_id, + "star_count": star_count, + "starboard_message_id": starboard_message_id, + }, + "update": { + "message_content": message_content, + "message_expires_at": message_expires_at, + "message_channel_id": message_channel_id, + "message_user_id": message_user_id, + "star_count": star_count, + "starboard_message_id": starboard_message_id, + }, + }, + ) + + async def delete_starboard_message(self, message_id: int, guild_id: int) -> StarboardMessage | None: + return await self.table.delete( + where={"message_id_message_guild_id": {"message_id": message_id, "message_guild_id": guild_id}}, + ) + + async def get_all_starboard_messages(self, guild_id: int) -> list[StarboardMessage]: + return await self.table.find_many(where={"message_guild_id": guild_id}) + + async def update_star_count(self, message_id: int, guild_id: int, new_star_count: int) -> StarboardMessage | None: + return await self.table.update( + where={"message_id_message_guild_id": {"message_id": message_id, "message_guild_id": guild_id}}, + data={"star_count": new_star_count}, + ) + + async def get_starboard_message_by_id(self, original_message_id: int, guild_id: int) -> StarboardMessage | None: + """ + Get a starboard message by its ID and guild ID. + This is the response by the bot, not the original message that was starred. + """ + return await self.table.find_first(where={"message_id": original_message_id, "message_guild_id": guild_id}) diff --git a/typings/emojis/__init__.pyi b/typings/emojis/__init__.pyi new file mode 100644 index 0000000..ee85d06 --- /dev/null +++ b/typings/emojis/__init__.pyi @@ -0,0 +1,10 @@ +""" +This type stub file was generated by pyright. +""" + +from .emojis import count, decode, encode, get, iter + +''' +Emojis for Python 🐍 +''' +__all__ = ['encode', 'decode', 'get', 'count', 'iter'] diff --git a/typings/emojis/db/__init__.pyi b/typings/emojis/db/__init__.pyi new file mode 100644 index 0000000..1870bfe --- /dev/null +++ b/typings/emojis/db/__init__.pyi @@ -0,0 +1,11 @@ +""" +This type stub file was generated by pyright. +""" + +from .db import Emoji +from .utils import get_categories, get_emoji_aliases, get_emoji_by_alias, get_emoji_by_code, get_emojis_by_category, get_emojis_by_tag, get_tags + +''' +Emoji database. +''' +__all__ = ['Emoji', 'get_emoji_aliases', 'get_emoji_by_code', 'get_emoji_by_alias', 'get_emojis_by_tag', 'get_emojis_by_category', 'get_tags', 'get_categories'] diff --git a/typings/emojis/db/db.pyi b/typings/emojis/db/db.pyi new file mode 100644 index 0000000..c4f0456 --- /dev/null +++ b/typings/emojis/db/db.pyi @@ -0,0 +1,6 @@ +""" +This type stub file was generated by pyright. +""" + +Emoji = ... +EMOJI_DB = ... diff --git a/typings/emojis/db/utils.pyi b/typings/emojis/db/utils.pyi new file mode 100644 index 0000000..18cf655 --- /dev/null +++ b/typings/emojis/db/utils.pyi @@ -0,0 +1,64 @@ +""" +This type stub file was generated by pyright. +""" + +def get_emoji_aliases(): # -> dict[Any, Any]: + ''' + Returns all Emojis as a dict (key = alias, value = unicode). + + :rtype: dict + ''' + ... + +def get_emoji_by_code(code): # -> None: + ''' + Returns Emoji by Unicode code. + + :param code: Emoji Unicode code. + :rtype: emojis.db.Emoji + ''' + ... + +def get_emoji_by_alias(alias): # -> Emoji | None: + ''' + Returns Emoji by alias. + + :param alias: Emoji alias. + :rtype: emojis.db.Emoji + ''' + ... + +def get_emojis_by_tag(tag): # -> filter[Emoji]: + ''' + Returns all Emojis from selected tag. + + :param tag: Tag name to filter (case-insensitive). + :rtype: iter + ''' + ... + +def get_emojis_by_category(category): # -> filter[Any]: + ''' + Returns all Emojis from selected category. + + :param tag: Category name to filter (case-insensitive). + :rtype: iter + ''' + ... + +def get_tags(): # -> set[Any]: + ''' + Returns all tags available. + + :rtype: set + ''' + ... + +def get_categories(): # -> set[Any]: + ''' + Returns all categories available. + + :rtype: set + ''' + ... + diff --git a/typings/emojis/emojis.pyi b/typings/emojis/emojis.pyi new file mode 100644 index 0000000..d8e9f6d --- /dev/null +++ b/typings/emojis/emojis.pyi @@ -0,0 +1,69 @@ +""" +This type stub file was generated by pyright. +""" + +ALIAS_TO_EMOJI = ... +EMOJI_TO_ALIAS = ... +EMOJI_TO_ALIAS_SORTED = ... +RE_TEXT_TO_EMOJI_GROUP = ... +RE_TEXT_TO_EMOJI = ... +RE_EMOJI_TO_TEXT_GROUP = ... +RE_EMOJI_TO_TEXT = ... +def encode(msg): # -> str: + ''' + Encode Emoji aliases into unicode Emoji values. + + :param msg: String to encode. + :rtype: str + + Usage:: + + >>> import emojis + >>> emojis.encode('This is a message with emojis :smile: :snake:') + 'This is a message with emojis 😄 🐍' + ''' + ... + +def decode(msg): # -> str: + ''' + Decode unicode Emoji values into Emoji aliases. + + :param msg: String to decode. + :rtype: str + + Usage:: + + >>> import emojis + >>> emojis.decode('This is a message with emojis 😄 🐍') + 'This is a message with emojis :smile: :snake:' + ''' + ... + +def get(msg): # -> set[str]: + ''' + Returns unique Emojis in the given string. + + :param msg: String to search for Emojis. + :rtype: set + ''' + ... + +def iter(msg): # -> Generator[str, None, None]: + ''' + Iterates over all Emojis found in the message. + + :param msg: String to search for Emojis. + :rtype: iterator + ''' + ... + +def count(msg, unique=...): # -> int: + ''' + Returns Emoji count in the given string. + + :param msg: String to search for Emojis. + :param unique: (optional) Boolean, return unique values only. + :rtype: int + ''' + ... +