1
Fork 0
mirror of https://github.com/allthingslinux/tux.git synced 2024-10-02 16:43:12 +00:00

refactor: First starboard prototype

This commit is contained in:
wlinator 2024-09-04 06:32:13 -04:00
parent a6c8aaabae
commit 2e2a3b21b4
12 changed files with 494 additions and 51 deletions

13
poetry.lock generated
View file

@ -679,6 +679,17 @@ files = [
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, {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]] [[package]]
name = "filelock" name = "filelock"
version = "3.15.4" version = "3.15.4"
@ -2425,4 +2436,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.12,<4" python-versions = ">=3.12,<4"
content-hash = "bbac8f5b09eb56eaa68724e6abfabfd587e57f38d101c5a421ee80c62c2e04dc" content-hash = "ff3627917ae2897246db3ffc167ff5dcb45f550119ee7116f453fd5091bac646"

View file

@ -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-models
// Docs: https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-attributes // Docs: https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-attributes
model Guild { model Guild {
guild_id BigInt @id guild_id BigInt @id
guild_joined_at DateTime? @default(now()) guild_joined_at DateTime? @default(now())
cases Case[] cases Case[]
snippets Snippet[] snippets Snippet[]
notes Note[] notes Note[]
reminders Reminder[] reminders Reminder[]
guild_config GuildConfig[] guild_config GuildConfig[]
Starboard Starboard[] Starboard Starboard[]
StarboardMessage StarboardMessage[]
@@unique([guild_id]) @@unique([guild_id])
@@index([guild_id]) @@index([guild_id])
@ -154,3 +155,19 @@ model Starboard {
@@unique([guild_id]) @@unique([guild_id])
@@index([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])
}

View file

@ -37,6 +37,7 @@ sentry-sdk = {extras = ["httpx", "loguru"], version = "^2.7.0"}
types-aiofiles = "^24.1.0.20240626" types-aiofiles = "^24.1.0.20240626"
types-psutil = "^6.0.0.20240621" types-psutil = "^6.0.0.20240621"
typing-extensions = "^4.12.2" typing-extensions = "^4.12.2"
emojis = "^0.7.0"
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.30" mkdocs-material = "^9.5.30"

View file

@ -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 <subcommand>",
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 <channel> <emoji> <threshold>",
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))

View file

@ -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))

View file

@ -4,7 +4,7 @@ from .guild_config import GuildConfigController
from .note import NoteController from .note import NoteController
from .reminder import ReminderController from .reminder import ReminderController
from .snippet import SnippetController from .snippet import SnippetController
from .starboard import StarboardController from .starboard import StarboardController, StarboardMessageController
class DatabaseController: class DatabaseController:
@ -16,3 +16,4 @@ class DatabaseController:
self.guild = GuildController() self.guild = GuildController()
self.guild_config = GuildConfigController() self.guild_config = GuildConfigController()
self.starboard = StarboardController() self.starboard = StarboardController()
self.starboard_message = StarboardMessageController()

View file

@ -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 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: async def delete_starboard_by_guild_id(self, guild_id: int) -> Starboard | None:
return await self.table.delete(where={"guild_id": guild_id}) 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})

View file

@ -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']

View file

@ -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']

6
typings/emojis/db/db.pyi Normal file
View file

@ -0,0 +1,6 @@
"""
This type stub file was generated by pyright.
"""
Emoji = ...
EMOJI_DB = ...

View file

@ -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
'''
...

69
typings/emojis/emojis.pyi Normal file
View file

@ -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
'''
...