diff --git a/.example.env b/.example.env index 1bb16e8..ad786a9 100644 --- a/.example.env +++ b/.example.env @@ -1,2 +1,4 @@ GUILD_ID=GUILD_ID BOT_TOKEN="BOT_TOKEN" +SKULLBOARD_CHANNEL_ID=SKULLBOARD_CHANNEL_ID +REQUIRED_REACTIONS=5 diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 749bb71..391132a 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -52,16 +52,12 @@ jobs: KEY: ${{ secrets.SSH_EC2_KEY }} HOSTNAME: ${{ secrets.SSH_EC2_HOSTNAME }} USER: ${{ secrets.SSH_EC2_USER }} - GUILD_ID: ${{ secrets.GUILD_ID }} - BOT_TOKEN: ${{ secrets.BOT_TOKEN }} run: | echo "$KEY" > private_key && chmod 600 private_key ssh -v -o StrictHostKeyChecking=no -i private_key ${USER}@${HOSTNAME} ' cd ~/duckbot aws s3 cp s3://${{ secrets.AWS_S3_BUCKET }}/duckbot/duckbot.tar.gz . aws s3 cp s3://${{ secrets.AWS_S3_BUCKET }}/duckbot/docker-compose.yml . - echo GUILD_ID=${{ secrets.GUILD_ID }} > .env - echo BOT_TOKEN=${{ secrets.BOT_TOKEN }} >> .env docker load -i duckbot.tar.gz docker compose up -d ' diff --git a/poetry.lock b/poetry.lock index 5f65b5e..9bd1902 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1631,4 +1631,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "4f9565156f980d97739575e85c822d8b32674b504f57e7c8135bd4d52abd73d2" +content-hash = "4f9565156f980d97739575e85c822d8b32674b504f57e7c8135bd4d52abd73d2" \ No newline at end of file diff --git a/src/commands/skullboard.py b/src/commands/skullboard.py new file mode 100644 index 0000000..132e9c6 --- /dev/null +++ b/src/commands/skullboard.py @@ -0,0 +1,151 @@ +import os +import requests +import re + +from discord import Embed, Client +from dotenv import load_dotenv + +from constants.colours import LIGHT_GREY + + +class SkullboardManager: + def __init__(self, client: Client): + """Initialise SkullboardManager""" + load_dotenv() # Load environment variables from .env file + self.client = client + self.required_reactions = int(os.getenv("REQUIRED_REACTIONS")) + + async def get_reaction_count(self, message, emoji): + """Get count of a specific emoji reaction on a message""" + return next( + ( + reaction.count + for reaction in message.reactions + if reaction.emoji == emoji + ), + 0, + ) + + async def handle_skullboard(self, message, skullboard_channel_id): + """Handle reactions and update/delete skullboard messages""" + skullboard_channel = self.client.get_channel(skullboard_channel_id) + if not skullboard_channel: + return + + emoji = "💀" + current_count = await self.get_reaction_count(message, emoji) + + await self.update_or_send_skullboard_message( + skullboard_channel, message, current_count, emoji + ) + + async def update_or_send_skullboard_message( + self, channel, message, current_count, emoji + ): + """Update or send skullboard message""" + skullboard_message_id = None + message_jump_url = message.jump_url + + async for skullboard_message in channel.history(limit=100): + if message_jump_url in skullboard_message.content: + skullboard_message_id = skullboard_message.id + break + + if current_count >= self.required_reactions: + if skullboard_message_id: + await self.edit_or_send_skullboard_message( + channel, + message, + current_count, + emoji, + send=False, + skullboard_message_id=skullboard_message_id, + ) + else: + await self.edit_or_send_skullboard_message( + channel, message, current_count, emoji, send=True + ) + elif skullboard_message_id: + skullboard_message = await channel.fetch_message(skullboard_message_id) + await skullboard_message.delete() + + @staticmethod + async def get_gif_url(view_url): + """Get URL of GIF from a Tenor view URL""" + # Get the page content + page_content = requests.get(view_url).text + + # Regex to find the URL on the media.tenor.com domain that ends with .gif + regex = r"(?i)\b((https?://media1[.]tenor[.]com/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))[.]gif)" + + # Find and return the first match + match = re.findall(regex, page_content) + + return match[0][0] if match else None + + async def edit_or_send_skullboard_message( + self, + channel, + message, + current_count, + emoji, + send=False, + skullboard_message_id=None, + ): + """Edit or send a skullboard message""" + # Fetch user's nickname and avatar url + guild = self.client.get_guild(message.guild.id) + member = guild.get_member(message.author.id) + user_nickname = member.nick if member.nick else message.author.name + user_avatar_url = message.author.avatar.url + + # Constructing the message content + message_jump_url = message.jump_url + message_content = f"{emoji} {current_count} | {message_jump_url}" + + # Constructing the embed + embed = Embed( + description=f"{message.content}\n\n", + timestamp=message.created_at, + colour=LIGHT_GREY, + ) + + if message.content.startswith("https://tenor.com/view/"): + # Constructing the embed + embed = Embed( + timestamp=message.created_at, + colour=LIGHT_GREY, + ) + + # Find the URL of the gif + gif_url = await self.get_gif_url(message.content) + + if gif_url: + embed.set_image(url=gif_url) + + # Set user nickname and thumbnail + embed.set_author(name=user_nickname, icon_url=user_avatar_url) + + # Add images, stickers, and attachments + if message.stickers: + # Replace the pattern with just the format type + format_type = str(message.stickers[0].format).split(".", maxsplit=1)[-1] + + sticker_id = message.stickers[0].id + sticker_url = f"https://media.discordapp.net/stickers/{ + sticker_id}.{format_type}" + embed.set_image(url=sticker_url) + + if message.attachments: + attachment = message.attachments[0] + if attachment.content_type.startswith("video"): + embed.add_field(name="", value=attachment.url) + else: + embed.set_image(url=attachment.url) + + # Determine if sending or editing the message + if send: + await channel.send(message_content, embed=embed) + else: + skullboard_message = await channel.fetch_message(skullboard_message_id) + await skullboard_message.edit(content=message_content, embed=embed) diff --git a/src/constants/colours.py b/src/constants/colours.py new file mode 100644 index 0000000..0073a8e --- /dev/null +++ b/src/constants/colours.py @@ -0,0 +1,3 @@ +from discord import Color + +LIGHT_GREY = Color.from_rgb(204, 214, 221) diff --git a/src/main.py b/src/main.py index 38230dd..490c914 100644 --- a/src/main.py +++ b/src/main.py @@ -1,26 +1,46 @@ import os import importlib import pkgutil -from discord import Intents, app_commands, Object, Interaction, Embed, Message, Color + +from discord import ( + Intents, + app_commands, + Object, + Interaction, + Embed, + Color, + Message, + RawReactionActionEvent, +) from discord.ext import commands from dotenv import load_dotenv +from commands import skullboard + # Load environment variables from .env file load_dotenv() # Retrieve guild ID and bot token from environment variables GUILD_ID = int(os.environ["GUILD_ID"]) BOT_TOKEN = os.environ["BOT_TOKEN"] +SKULLBOARD_CHANNEL_ID = int(os.environ["SKULLBOARD_CHANNEL_ID"]) # Load the permissions the bot has been granted in the previous configuration intents = Intents.default() +intents.guilds = True +intents.messages = True intents.message_content = True +intents.reactions = True +intents.members = True class DuckBot(commands.Bot): def __init__(self): super().__init__(command_prefix="", intents=intents) self.synced = False # Make sure that the command tree will be synced only once + self.skullboard_manager = skullboard.SkullboardManager( + self + ) # Initialise SkullboardManager async def setup_hook(self): # Dynamically load all command groups from the commands directory @@ -38,6 +58,31 @@ async def setup_hook(self): async def on_ready(self): print(f"Say hi to {self.user}!") + # Override on_message method with correct parameters + async def on_message(self, message): + pass + + # Register the reaction handling + async def on_raw_reaction_add(self, payload: RawReactionActionEvent): + if payload.emoji.name == "💀": + channel = self.get_channel(payload.channel_id) + message = await channel.fetch_message(payload.message_id) + # Ignore reactions to own messages + if message.author.id != self.user.id: + await self.skullboard_manager.handle_skullboard( + message, SKULLBOARD_CHANNEL_ID + ) + + async def on_raw_reaction_remove(self, payload: RawReactionActionEvent): + if payload.emoji.name == "💀": + channel = self.get_channel(payload.channel_id) + message = await channel.fetch_message(payload.message_id) + # Ignore reactions to own messages + if message.author.id != self.user.id: + await self.skullboard_manager.handle_skullboard( + message, SKULLBOARD_CHANNEL_ID + ) + client = DuckBot()