From adf5c792b3e50792d44c9cadc3ab8edc9a93c2dd Mon Sep 17 00:00:00 2001 From: Persi Dev <109619062+devpersi@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:20:44 +0300 Subject: [PATCH 1/3] Revamp ticket command (#27) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .env.sample | 2 +- README.md | 13 ++-- bot/config.py | 4 +- bot/messages.py | 42 +++++----- bot/modals/ticket_modal.py | 6 +- bot/ticket_cog.py | 156 ++++++++++++------------------------- bot/views/ticket_view.py | 3 +- 7 files changed, 82 insertions(+), 144 deletions(-) diff --git a/.env.sample b/.env.sample index acd9acd..5d345db 100644 --- a/.env.sample +++ b/.env.sample @@ -11,4 +11,4 @@ COC_THREAD_PREFIX=welcome TICKET_HOLDER_ROLE_NAME=ticketholders TICKET_MESSAGE_LINK= TICKET_THREAD_PREFIX=ticket -TICKET_MESSAGE_EXPIRES_AFTER= +BOT_INTERACTIONS_CHANNEL_ID= diff --git a/README.md b/README.md index c9e6e76..865faa8 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,16 @@ # PyGreece Discord Bot 🤖 -A Discord bot for the PyGreece online community that handles member onboarding through a Code of Conduct acceptance flow. +A Discord bot for the PyGreece online community that handles member onboarding and through a Code of Conduct acceptance flow. +It also implements a ticket verification system. ## ✨ Features - 👋 Automatically sends welcome messages to new members - 📜 Implements a Code of Conduct acceptance workflow -- 🏷️ Assigns roles when members react to the Code of Conduct message +- 🏷️ Assigns a role when members react to the Code of Conduct message +- 🚧 Handles ticket verification workflow +- 🎟️ Assigns a role when members submit their ticket IDs - 🗄️ Tracks member status in a database ## 🔧 Requirements @@ -37,7 +40,7 @@ A Discord bot for the PyGreece online community that handles member onboarding t ### Environment Configuration Copy `.env.sample` to a new file called `.env` and update the placeholder values: - ```dosini +```dosini DISCORD_TOKEN= DISCORD_GUILD= ORGANIZER_ROLE_NAME=organizers @@ -51,8 +54,8 @@ COC_THREAD_PREFIX=welcome TICKET_HOLDER_ROLE_NAME=ticketholders TICKET_MESSAGE_LINK= TICKET_THREAD_PREFIX=ticket -TICKET_MESSAGE_EXPIRES_AFTER= - ``` +BOT_INTERACTIONS_CHANNEL_ID= +``` > Use `compose.yml` to set DB credentials diff --git a/bot/config.py b/bot/config.py index 894f10a..1651c92 100644 --- a/bot/config.py +++ b/bot/config.py @@ -45,9 +45,7 @@ def get_env_var_int(name: str, default: int | None = None) -> int: TICKET_MESSAGE_ID = int(TICKET_MESSAGE_LINK.split("/")[-1]) TICKET_CHANNEL_ID = int(TICKET_MESSAGE_LINK.split("/")[-2]) TICKET_THREAD_PREFIX = get_env_var("TICKET_CHANNEL_PREFIX", "ticket-verification") -TICKET_MESSAGE_EXPIRES_AFTER = get_env_var_int( - "TICKET_EXPIRES_AFTER", 5 * 60 -) # Default to 5 minutes +BOT_INTERACTIONS_CHANNEL_ID = get_env_var_int("BOT_INTERACTIONS_CHANNEL_ID") ACCEPTABLE_REACTION_EMOJIS = [ "👍", # Thumbs up - approval diff --git a/bot/messages.py b/bot/messages.py index bf37f9f..5a286cb 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -25,40 +25,32 @@ ### Ticket messages NEW_MEMBER_TICKET_MESSAGE = ( - "Είσαι επίσημα μέλος {name}! Μήπως έχεις και εισιτήριο για το PyCon Greece 2025; Στείλε στο τσατ !ticket " - "κενό και τον αριθμό παραγγελίας σου (μπορείς να βρεις τον αριθμό παραγγελίας στο email που πήρες " - "όταν αγόρασες το εισιτήριο - ψάξε PyCon Greece 2025 στο inbox σου) ή χρησιμοποίησε το παρακάτω κουμπί " + "Είσαι επίσημα μέλος {name}! Μήπως έχεις και εισιτήριο για το PyCon Greece 2025; Πάτησε το παρακάτω κουμπί " + "και συμπλήρωσε τον αριθμό παραγγελίας σου (μπορείς να βρεις τον αριθμό παραγγελίας στο email που πήρες " + "όταν αγόρασες το εισιτήριο - ψάξε PyCon Greece 2025 στο inbox σου) " "για να αποκτήσεις πρόσβαση στα κανάλια της εκδήλωσης! 😊\n\n" "---\n\n" - "You are officialy a member {name}! Do you also happen to have a ticket for PyCon Greece 2025? Reply with !ticket space " - "and your order number (you can find the order number in the email you received when you bought your ticket - " - "search for PyCon Greece 2025 in your inbox) in this chat or click the button below to get access to the channels of the event! 😊 " + "You are officialy a member {name}! Do you also happen to have a ticket for PyCon Greece 2025? Click the button below " + "and enter your order number (you can find the order number in the email you received when you bought your ticket - " + "search for PyCon Greece 2025 in your inbox) to get access to the channels of the event! 😊 " ) ASK_FOR_TICKET_MESSAGE = ( - "Γειά σου {name}, έχεις εισιτήριο για το PyCon Greece 2025; Στείλε στο τσατ !ticket " - "κενό και τον αριθμό παραγγελίας σου (μπορείς να βρεις τον αριθμό παραγγελίας στο email που πήρες " - "όταν αγόρασες το εισιτήριο - ψάξε PyCon Greece 2025 στο inbox σου) ή χρησιμοποίησε το παρακάτω κουμπί " + "Γειά σου {name}, έχεις εισιτήριο για το PyCon Greece 2025; Πάτησε το παρακάτω κουμπί " + "και συμπλήρωσε τον αριθμό παραγγελίας σου (μπορείς να βρεις τον αριθμό παραγγελίας στο email που πήρες " + "όταν αγόρασες το εισιτήριο - ψάξε PyCon Greece 2025 στο inbox σου) " "για να αποκτήσεις πρόσβαση στα κανάλια της εκδήλωσης! 😊\n\n" "---\n\n" - "Hey {name}, do you happen to have a ticket for PyCon Greece 2025? Send !ticket space " - "and your order number (you can find the order number in the email you received when you bought your ticket - " - "search for PyCon Greece 2025 in your inbox) in this chat or click the button below to get access to the channels of the event! 😊 " + "Hey {name}, do you happen to have a ticket for PyCon Greece 2025? Click the button below " + "and enter your order number (you can find the order number in the email you received when you bought your ticket - " + "search for PyCon Greece 2025 in your inbox) to get access to the channels of the event! 😊 " ) ### Ticket Errors -TICKET_INVALID_THREAD_MESSAGE = ( - "Αυτή η εντολή μπορεί να χρησιμοποιηθεί μόνο στο προσωπικό σου νήμα στο κανάλι για τα εισιτήρια. " - "Αν δεν υπάρχει νήμα τότε αντίδρασε με ένα thumbs-up (👍) στο [μήνυμα για τα εισιτήρια]({link}).\n\n" +TICKET_INVALID_CHANNEL_MESSAGE = ( + "Αυτή η εντολή μπορεί να χρησιμοποιηθεί μόνο στο κανάλι {channel}. ⛔\n\n" "---\n\n" - "This command can only be used in the user's thread in the ticket channel. " - "Please react to the [ticket message]({link}) with a thumbs-up (👍) if the thread is missing. " -) - -TICKET_ID_MISSING_MESSAGE = ( - "Παρακαλώ συμπερίλαβε τον αριθμό παραγγελίας μετά το !ticket.\n\n" - "---\n\n" - "Please provide an order ID after !ticket. " + "This command can only be used in the {channel} channel. ⛔\n\n" ) TICKET_INVALID_ID_MESSAGE = ( @@ -116,7 +108,9 @@ ) TICKET_ACCEPTED_MESSAGE = ( - "Ευχαριστώ που επικύρωσες το εισιτήριό σου {name}! Μπορείς να συμμετέχεις στα κανάλια της εκδήλωσης! 😊\n\n" + "Ευχαριστώ που επικύρωσες το εισιτήριό σου {name}! Μπορείς να συμμετέχεις στα κανάλια της εκδήλωσης! 😊 " + "(αυτό το νήμα θα αυτοκαταστραφεί σε 45 δευτερόλεπτα ⏱️)\n\n" "---\n\n" "Thank you for verifying your ticket {name}! You can now join the channels of the event! 😊 " + "(the thread will self-destruct in 45 seconds ⏱️) " ) diff --git a/bot/modals/ticket_modal.py b/bot/modals/ticket_modal.py index dca8531..c2c02f7 100644 --- a/bot/modals/ticket_modal.py +++ b/bot/modals/ticket_modal.py @@ -62,13 +62,13 @@ async def on_submit(self, interaction: Interaction) -> None: except exceptions.InvalidTicketIdException: self.success = False await interaction.response.send_message( - messages.TICKET_INVALID_ID_MESSAGE, ephemeral=True, delete_after=10 + messages.TICKET_INVALID_ID_MESSAGE, ephemeral=True, delete_after=30 ) return except exceptions.TicketHolderRoleAlreadyAssignedException: self.success = False await interaction.response.send_message( - messages.TICKET_MEMBER_ALREADY_CLAIMED_MESSAGE, ephemeral=True, delete_after=10 + messages.TICKET_MEMBER_ALREADY_CLAIMED_MESSAGE, ephemeral=True, delete_after=30 ) return except exceptions.MemberHasNotReactedToCocException: @@ -76,7 +76,7 @@ async def on_submit(self, interaction: Interaction) -> None: await interaction.response.send_message( messages.COC_NOT_ACCEPTED_MESSAGE.format(link=config.COC_MESSAGE_LINK), ephemeral=True, - delete_after=10, + delete_after=30, ) return except exceptions.TicketAlreadyClaimedException: diff --git a/bot/ticket_cog.py b/bot/ticket_cog.py index df30dc5..806757f 100644 --- a/bot/ticket_cog.py +++ b/bot/ticket_cog.py @@ -3,24 +3,16 @@ import discord from discord.ext import commands -from bot import config, exceptions, messages -from bot.roles import get_random_member_from_role, member_has_role -from bot.sanitizers import sanitize_ticket_id +from bot import config, messages +from bot.roles import member_has_role from bot.senders import delete_private_thread, send_private_message_in_thread -from bot.services.ticket_services import claim_ticket -from bot.validations.ticket_validation import can_claim_ticket from bot.views.ticket_view import TicketView logger = logging.getLogger(__name__) class TicketVerification(commands.Cog): - """The cog that implements ticket verification for the PyGreece Discord bot. - - This class implements functionality for handling ticket verifications. It sends messages to - new members and members who react to the relevant ticket message and assigns the ticket holder - role based on commands. - """ + """Commands related to the ticket verification system.""" def __init__(self, bot: commands.Bot) -> None: """Called when the cog is initialized.""" @@ -76,123 +68,73 @@ async def on_member_remove(self, member: discord.Member) -> None: @commands.hybrid_command() @commands.guild_only() - async def ticket(self, ctx: commands.Context[commands.Bot], ticket_id: str = "") -> None: - """Allows members to claim tickets by typing !ticket .""" + async def ticket(self, ctx: commands.Context[commands.Bot]) -> None: + """Claim tickets by typing !ticket to start a thread. | Επικύρωσε το εισιτήριό σου γράφοντας !ticket για να ξεκινήσεις ένα νήμα. + + Click the button in the private thread created by the bot + to claim your ticket and join the event channels. + You need to have reacted to the coc message and + have the community member role in order to use this command. + + Κάνε κλικ στο κουμπί στο προσωπικό νήμα που δημιουργεί το bot + για να επικυρώσεις το εισιτήριό σου και να αποκτήσεις πρόσβαση στα κανάλια της εκδήλωσης. + Θα πρέπει να έχεις αντιδράσει στον κώδικα δεοντολογίας και + να έχεις το ρόλο community member για να χρησιμοποιήσεις αυτήν την εντολή. + """ - # The decorator guild_only() explicitly ensures that ctx.guild is not None assert ctx.guild is not None, "This command can only be used in a guild." assert isinstance(ctx.author, discord.Member), "Ticket command must be used by a member." - logger.info(f"Ticket command received from {ctx.author.name}.") - # Ensure command is used in a private ticket channel - is_valid_thread = ( - isinstance(ctx.channel, discord.Thread) - and ctx.channel.parent_id == config.TICKET_CHANNEL_ID - and member_has_role(ctx.author, config.MEMBER_ROLE_NAME) - ) - if not is_valid_thread: + if not member_has_role(ctx.author, config.MEMBER_ROLE_NAME): await ctx.message.delete() - await ctx.send( - messages.TICKET_INVALID_THREAD_MESSAGE.format(link=config.TICKET_MESSAGE_LINK), + await ctx.reply( + messages.COC_NOT_ACCEPTED_MESSAGE.format(link=config.COC_MESSAGE_LINK), ephemeral=True, - delete_after=10, + delete_after=30, ) - logger.warning( - f"Ticket command received from {ctx.author.name} in an invalid channel." + logger.info( + f"Member {ctx.author.name} ({ctx.author.id}) does not have the {config.MEMBER_ROLE_NAME} role." + f"Ticket command ignored." ) return - assert isinstance(ctx.channel, discord.Thread), ( - "Ticket command must be used in a private thread." - ) - assert isinstance(ctx.author, discord.Member), "Ticket command must be used by a member." - if member_has_role(ctx.author, config.TICKET_HOLDER_ROLE_NAME): + await ctx.message.delete() + await ctx.reply( + messages.TICKET_MEMBER_ALREADY_CLAIMED_MESSAGE, ephemeral=True, delete_after=30 + ) logger.info( f"Member {ctx.author.name} ({ctx.author.id}) already has the {config.TICKET_HOLDER_ROLE_NAME} role." + f"Ticket command ignored." ) return - organizer_role = discord.utils.get(ctx.guild.roles, name=config.ORGANIZER_ROLE_NAME) - if not organizer_role: - logger.error("The ticket cog could not find the organizer role.") - return - try: - random_organizer = get_random_member_from_role(organizer_role) - except exceptions.EmptyRoleException as e: - logger.error(e) - return + if ctx.channel.id != config.BOT_INTERACTIONS_CHANNEL_ID: + bot_channel = self.bot.get_channel(config.BOT_INTERACTIONS_CHANNEL_ID) + if not (bot_channel and isinstance(bot_channel, discord.TextChannel)): + logger.error("Could not find the bot interactions channel.") + return - # Remove the hashtag and/or whitespace from the ticket ID - ticket_id = sanitize_ticket_id(ticket_id) - try: - member_can_claim_ticket = await can_claim_ticket(ctx.author, ticket_id) - except exceptions.UserNotMemberException as e: - logger.error(f"Error claiming ticket for {ctx.author.name} ({ctx.author.id}): {e}") - return - except exceptions.InvalidTicketIdException: - await ctx.send(messages.TICKET_INVALID_ID_MESSAGE, ephemeral=True, delete_after=10) - return - except exceptions.TicketHolderRoleAlreadyAssignedException: - await ctx.send( - messages.TICKET_MEMBER_ALREADY_CLAIMED_MESSAGE, ephemeral=True, delete_after=10 - ) - return - except exceptions.MemberHasNotReactedToCocException: + await ctx.message.delete() await ctx.send( - messages.COC_NOT_ACCEPTED_MESSAGE.format(link=config.COC_MESSAGE_LINK), + messages.TICKET_INVALID_CHANNEL_MESSAGE.format(channel=bot_channel.mention), ephemeral=True, - delete_after=10, + delete_after=30, ) - return - except exceptions.TicketAlreadyClaimedException: - await ctx.channel.add_user(random_organizer) - await ctx.send( - messages.TICKET_MEMBER_ALREADY_CLAIMED_WITH_NO_ROLE_MESSAGE.format( - role=organizer_role.mention - ) - ) - return - except exceptions.TicketNotFoundInDatabaseException: - await ctx.channel.add_user(random_organizer) - await ctx.send( - messages.TICKET_NOT_FOUND_IN_DATABASE_MESSAGE.format(role=organizer_role.mention) - ) - return - except Exception as e: - await ctx.channel.add_user(random_organizer) - await ctx.send(messages.TICKET_DB_ERROR_MESSAGE.format(role=organizer_role.mention)) - logger.error(f"Error claiming ticket for {ctx.author.name} ({ctx.author.id}): {e}") - return - - if not member_can_claim_ticket: - await ctx.channel.add_user(random_organizer) - await ctx.send( - messages.TICKET_GENERIC_ERROR_MESSAGE.format(role=organizer_role.mention) - ) - logger.error(f"Error claiming ticket for {ctx.author.name} ({ctx.author.id})") - return - - try: - member_claimed_ticket = await claim_ticket(ctx.author, int(ticket_id)) - except exceptions.RoleAssignmentFailedException: - await ctx.channel.add_user(random_organizer) - await ctx.send( - messages.TICKET_ROLE_ASSIGNMENT_ERROR_MESSAGE.format(role=organizer_role.mention) + logger.warning( + f"Ticket command received from {ctx.author.name} in an invalid channel." ) return - except Exception as e: - await ctx.channel.add_user(random_organizer) - await ctx.send(messages.TICKET_DB_ERROR_MESSAGE.format(role=organizer_role.mention)) - logger.error(f"Error claiming ticket for {ctx.author.name} ({ctx.author.id}): {e}") - return - if not member_claimed_ticket: - await ctx.channel.add_user(random_organizer) - await ctx.send( - messages.TICKET_GENERIC_ERROR_MESSAGE.format(role=organizer_role.mention) - ) - logger.error(f"Error claiming ticket for {ctx.author.name} ({ctx.author.id})") - return + logger.info( + f"Ticket command received from {ctx.author.name} ({ctx.author.id}) in the bot interactions channel." + ) - await ctx.send(messages.TICKET_ACCEPTED_MESSAGE.format(name=ctx.author.mention)) + await send_private_message_in_thread( + config.TICKET_CHANNEL_ID, + config.TICKET_THREAD_PREFIX, + ctx.author, + messages.ASK_FOR_TICKET_MESSAGE.format(name=ctx.author.mention), + f"private {config.TICKET_THREAD_PREFIX} thread", + view=TicketView(ctx.author), + ) diff --git a/bot/views/ticket_view.py b/bot/views/ticket_view.py index eaca121..7005a69 100644 --- a/bot/views/ticket_view.py +++ b/bot/views/ticket_view.py @@ -13,7 +13,7 @@ class TicketView(BaseView): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.timeout = config.TICKET_MESSAGE_EXPIRES_AFTER # 5 minutes timeout + self.timeout = None @discord.ui.button( label="Επικύρωσε το εισιτήριό σου! | Claim your ticket!", @@ -32,6 +32,7 @@ async def button_callback( button.style = discord.ButtonStyle.success button.emoji = discord.PartialEmoji(name="✅") button.disabled = True + self.timeout = 45 else: button.label = "Προσπάθησε ξανά | Try again" button.emoji = discord.PartialEmoji(name="🔄") From 9698ac5bcb05b991597d8232468dc7a237aeffc0 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Thu, 4 Sep 2025 16:57:50 +0300 Subject: [PATCH 2/3] Deactivate opening ticket thread after reacting to CoC (#28) --- bot/welcome_and_coc_cog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/welcome_and_coc_cog.py b/bot/welcome_and_coc_cog.py index 63ca13f..c0f420d 100644 --- a/bot/welcome_and_coc_cog.py +++ b/bot/welcome_and_coc_cog.py @@ -120,7 +120,9 @@ async def on_member_reacted_to_coc(self, member: discord.Member) -> None: logger.error(f"Error updating reacted for {member.name} ({member.id}): {e}") return - self.bot.dispatch("new_member_reacted_to_coc", member) + # This opens a new thread for ticket verification. Should be deactivate when a PyCon is not + # on. TODO: Reactivate it when PyCon Greece 2026 is in the works. + # self.bot.dispatch("new_member_reacted_to_coc", member) await delete_private_thread( config.COC_CHANNEL_ID, From 3ddf729fe3adba33acd2ba6d6e203101359800ca Mon Sep 17 00:00:00 2001 From: Persi Dev <109619062+devpersi@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:58:18 +0300 Subject: [PATCH 3/3] Update README.md (#29) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 865faa8..70838ea 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ It also implements a ticket verification system. ## 🔧 Requirements - 🐍 Python 3.12+ +- 📚 discord.py library - 🐘 PostgreSQL database (for production) - 🔑 Discord Bot Token @@ -102,7 +103,7 @@ docker-compose up -d - `models.py`: Database models - `roles.py`: Role related functions - `sanitizers.py`: String sanitizers - - `senders.py`: Sends dms, creates private categories and channels if dms are closed + - `senders.py`: Sends messages, creates and deletes the relevant private threads - `ticket_cog.py`: Ticket verification system - `utility_cog.py`: Administration commands - main cog - `utility_tasks.py`: Background tasks