Skip to content

Commit

Permalink
Skullboard (#14)
Browse files Browse the repository at this point in the history
* feat: Add skullboard that updates based on skull count

* refactor Move Adelaide time function to seperate file and combine shared lines

* feat: Handles reacts to messages before server started

* chore: Replace utcnow with now

* refactor: Move required reactions count and data file name to .env

* refactor: Update skullboard message handling logic

* fix: Use message time instead of current time

* refactor: Move skullboard functions into a class

* feat: Add user nickname and avatar to skullboard messages

* fix: Ignore reactions to own messages

* style: Run Black

* refactor: Make skullboard function without data file

* chore: Add colour to skullboard embed

* chore: Update production workflow to use environment variables for skullboard

* feature: Support images, stickers, and gifs

* fix: Update pyproject.toml

* style: Run Black

* chore: Clean up code

* feat: Handle video attachments

* chore: Remove unneeded environment variables in production workflow

* refactor: Improve code organisation and readability based on feedback

* style: Seperate imports by type
  • Loading branch information
phoenixpereira authored Jul 23, 2024
1 parent 4bcff60 commit 38a7add
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
GUILD_ID=GUILD_ID
BOT_TOKEN="BOT_TOKEN"
SKULLBOARD_CHANNEL_ID=SKULLBOARD_CHANNEL_ID
REQUIRED_REACTIONS=5
4 changes: 0 additions & 4 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
'
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions src/commands/skullboard.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/constants/colours.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from discord import Color

LIGHT_GREY = Color.from_rgb(204, 214, 221)
47 changes: 46 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand Down

0 comments on commit 38a7add

Please sign in to comment.