From 5f7734e2f59d221887fbb00e96290ec7eab6256b Mon Sep 17 00:00:00 2001 From: Lilac-Rose Date: Tue, 21 Oct 2025 00:12:05 +0200 Subject: [PATCH] Improvements to the suggestion feature, making buttons persistent between restarts, allowing me to send a reason for denying a suggestion, and updating suggestionlist to have filtering --- suggestion/suggest.py | 313 ++++++++++++++++++++++++++++-------------- 1 file changed, 208 insertions(+), 105 deletions(-) diff --git a/suggestion/suggest.py b/suggestion/suggest.py index 7cbf583..179958e 100644 --- a/suggestion/suggest.py +++ b/suggestion/suggest.py @@ -4,16 +4,167 @@ from discord.ext import commands import aiosqlite from datetime import datetime import os +from typing import Optional ADMIN_ID = 252130669919076352 +class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"): + reason = discord.ui.TextInput(label="Reason (optional)", style=discord.TextStyle.long, required=False, max_length=2000) + + def __init__(self, suggestion_id: int, user_id: int, suggestion_text: str, channel_id: int, admin_message_id: Optional[int], bot: commands.Bot): + super().__init__() + self.suggestion_id = suggestion_id + self.user_id = user_id + self.suggestion_text = suggestion_text + self.channel_id = channel_id + self.admin_message_id = admin_message_id + self.bot = bot + + async def on_submit(self, interaction: discord.Interaction): + reason_text = self.reason.value or None + + db_path = os.path.join(os.path.dirname(__file__), "suggestions.db") + async with aiosqlite.connect(db_path) as db: + await db.execute("UPDATE suggestions SET status = ?, reason = ? WHERE id = ?", ("Denied", reason_text, self.suggestion_id)) + await db.commit() + + # Respond to modal submit + await interaction.response.send_message(f"❌ Suggestion #{self.suggestion_id} denied.", ephemeral=False) + + # Notify user + try: + user = await self.bot.fetch_user(self.user_id) + dm_note = f"❌ Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **denied**." + if reason_text: + dm_note += f"\n**Reason:** {reason_text}" + await user.send(dm_note) + except: + pass + + # Notify original channel + channel = self.bot.get_channel(self.channel_id) + if channel: + try: + msg = f"❌ Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been **denied**." + if reason_text: + msg += f"\n**Reason:** {reason_text}" + await channel.send(msg) + except: + pass + + # Disable buttons on admin message (if we have the message id) + if self.admin_message_id: + try: + admin_user = await self.bot.fetch_user(ADMIN_ID) + dm = admin_user.dm_channel or await admin_user.create_dm() + orig_msg = await dm.fetch_message(self.admin_message_id) + # Create a view with same custom_ids but disabled + disabled_view = SuggestionButtons(self.bot, suggestion_id=self.suggestion_id, user_id=self.user_id, suggestion_text=self.suggestion_text, channel_id=self.channel_id, admin_message_id=self.admin_message_id, disabled=True) + await orig_msg.edit(view=disabled_view) + except Exception: + pass + + +class SuggestionButtons(discord.ui.View): + def __init__(self, bot, suggestion_id=None, user_id=None, suggestion_text=None, channel_id=None, admin_message_id: Optional[int]=None, disabled: bool=False): + # timeout=None to make persistent + super().__init__(timeout=None) + self.bot = bot + self.suggestion_id = suggestion_id + self.user_id = user_id + self.suggestion_text = suggestion_text + self.channel_id = channel_id + self.admin_message_id = admin_message_id + + # custom_id includes message id so callbacks can find the message later; message id may be None for re-registering but that's fine + approve_cid = f"suggest_approve_{suggestion_id}_{admin_message_id or 0}" + deny_cid = f"suggest_deny_{suggestion_id}_{admin_message_id or 0}" + + approve_btn = discord.ui.Button( + label="Approve ✅", + style=discord.ButtonStyle.success, + custom_id=approve_cid + ) + approve_btn.callback = self.approve + approve_btn.disabled = disabled + self.add_item(approve_btn) + + deny_btn = discord.ui.Button( + label="Deny ❌", + style=discord.ButtonStyle.danger, + custom_id=deny_cid + ) + deny_btn.callback = self.deny + deny_btn.disabled = disabled + self.add_item(deny_btn) + + async def approve(self, interaction: discord.Interaction): + if interaction.user.id != ADMIN_ID: + await interaction.response.send_message("❌ You can't approve suggestions.", ephemeral=True) + return + + if not self.suggestion_id: + await interaction.response.send_message("⚠️ This button is no longer active.", ephemeral=True) + return + + db_path = os.path.join(os.path.dirname(__file__), "suggestions.db") + async with aiosqlite.connect(db_path) as db: + await db.execute("UPDATE suggestions SET status = ? WHERE id = ?", ("Approved", self.suggestion_id)) + await db.commit() + + await interaction.response.send_message(f"✅ Suggestion #{self.suggestion_id} approved.", ephemeral=False) + + # Notify user + try: + user = await self.bot.fetch_user(self.user_id) + await user.send(f"✅ Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **approved!**") + except: + pass + + # Notify original channel + channel = self.bot.get_channel(self.channel_id) + if channel: + await channel.send(f"✅ Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been **approved!**") + + # Disable buttons after action by editing the admin message (if present) + if self.admin_message_id: + try: + admin_user = await self.bot.fetch_user(ADMIN_ID) + dm = admin_user.dm_channel or await admin_user.create_dm() + orig_msg = await dm.fetch_message(self.admin_message_id) + disabled_view = SuggestionButtons(self.bot, suggestion_id=self.suggestion_id, user_id=self.user_id, suggestion_text=self.suggestion_text, channel_id=self.channel_id, admin_message_id=self.admin_message_id, disabled=True) + await orig_msg.edit(view=disabled_view) + except Exception: + # fallback: try to disable children of THIS view and edit message (if interaction.message exists) + for item in self.children: + item.disabled = True + try: + await interaction.message.edit(view=self) + except: + pass + + async def deny(self, interaction: discord.Interaction): + if interaction.user.id != ADMIN_ID: + await interaction.response.send_message("❌ You can't deny suggestions.", ephemeral=True) + return + + if not self.suggestion_id: + await interaction.response.send_message("⚠️ This button is no longer active.", ephemeral=True) + return + + # Show a modal to capture reason + modal = DenyModal(suggestion_id=self.suggestion_id, user_id=self.user_id, suggestion_text=self.suggestion_text, channel_id=self.channel_id, admin_message_id=self.admin_message_id, bot=self.bot) + await interaction.response.send_modal(modal) + + class Suggestion(commands.Cog): def __init__(self, bot): self.bot = bot self.db_path = os.path.join(os.path.dirname(__file__), "suggestions.db") + self.db = None async def cog_load(self): - # Connect to database + # Connect to database and ensure schema (including reason and admin_message_id) self.db = await aiosqlite.connect(self.db_path) await self.db.execute(""" CREATE TABLE IF NOT EXISTS suggestions ( @@ -21,13 +172,30 @@ class Suggestion(commands.Cog): user_id INTEGER, suggestion TEXT, status TEXT, - channel_id INTEGER + channel_id INTEGER, + reason TEXT, + admin_message_id INTEGER ) """) await self.db.commit() + # Register persistent views for pending suggestions so buttons keep working after restarts. + async with self.db.execute("SELECT id, user_id, suggestion, channel_id, admin_message_id FROM suggestions WHERE status = ?", ("Pending",)) as cursor: + rows = await cursor.fetchall() + + for sid, uid, suggestion_text, channel_id, admin_msg_id in rows: + # Only re-register views if we have an admin_message_id (message was sent) + # but even if admin_msg_id is None, we register a view with admin_message_id None -> custom_id uses 0 + v = SuggestionButtons(self.bot, suggestion_id=sid, user_id=uid, suggestion_text=suggestion_text, channel_id=channel_id, admin_message_id=admin_msg_id) + try: + # registers the view for persistent components handling + self.bot.add_view(v) + except Exception: + pass + async def cog_unload(self): - await self.db.close() + if self.db: + await self.db.close() @app_commands.command(name="suggest", description="Submit a suggestion") async def suggest(self, interaction: discord.Interaction, idea: str): @@ -59,7 +227,18 @@ class Suggestion(commands.Cog): embed.add_field(name="Channel", value=f"<#{interaction.channel_id}>") view = SuggestionButtons(self.bot, suggestion_id, interaction.user.id, idea, interaction.channel_id) - await admin.send(embed=embed, view=view) + sent = await admin.send(embed=embed, view=view) + + # store admin message id so we can re-register persistent view for it later and edit it + await self.db.execute("UPDATE suggestions SET admin_message_id = ? WHERE id = ?", (sent.id, suggestion_id)) + await self.db.commit() + + # Register the view so the component interactions will be handled (persistent) + try: + self.bot.add_view(SuggestionButtons(self.bot, suggestion_id, interaction.user.id, idea, interaction.channel_id, admin_message_id=sent.id)) + except Exception: + pass + except Exception as e: print(f"Failed to send DM to admin: {e}") @@ -107,117 +286,41 @@ class Suggestion(commands.Cog): if channel: await channel.send(f"🎉 Suggestion **#{suggestion_id}** (`{suggestion_text}`) has been marked as **completed!**") - @app_commands.command(name="suggestion_list", description="List all suggestions (admin only)") - async def suggestion_list(self, interaction: discord.Interaction): - if interaction.user.id != ADMIN_ID: - await interaction.response.send_message("❌ You don't have permission to view this.", ephemeral=True) - return + @app_commands.command(name="suggestion_list", description="List suggestions (use status filter to narrow)") + @app_commands.choices(status=[ + app_commands.Choice(name="All", value="All"), + app_commands.Choice(name="Pending", value="Pending"), + app_commands.Choice(name="Approved", value="Approved"), + app_commands.Choice(name="Denied", value="Denied"), + app_commands.Choice(name="Completed", value="Completed") + ]) + async def suggestion_list(self, interaction: discord.Interaction, status: app_commands.Choice[str]): + # This command is intentionally not admin-restricted and not ephemeral (per request). + selected = status.value if status else "All" - async with self.db.execute("SELECT id, user_id, suggestion, status FROM suggestions ORDER BY id DESC") as cursor: + if selected == "All": + query = "SELECT id, user_id, suggestion, status FROM suggestions ORDER BY id DESC" + params = () + else: + query = "SELECT id, user_id, suggestion, status FROM suggestions WHERE status = ? ORDER BY id DESC" + params = (selected,) + + async with self.db.execute(query, params) as cursor: rows = await cursor.fetchall() if not rows: - await interaction.response.send_message("No suggestions found.", ephemeral=True) + await interaction.response.send_message("No suggestions found.", ephemeral=False) return - embed = discord.Embed(title="📋 Suggestions", color=discord.Color.green()) - for sid, uid, suggestion, status in rows: + embed = discord.Embed(title=f"📋 Suggestions — {selected}", color=discord.Color.green()) + for sid, uid, suggestion_text, st in rows: embed.add_field( - name=f"ID: {sid} | Status: {status}", - value=f"<@{uid}> — {suggestion[:100]}{'...' if len(suggestion) > 100 else ''}", + name=f"ID: {sid} | Status: {st}", + value=f"<@{uid}> — {suggestion_text[:100]}{'...' if len(suggestion_text) > 100 else ''}", inline=False ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -class SuggestionButtons(discord.ui.View): - def __init__(self, bot, suggestion_id=None, user_id=None, suggestion_text=None, channel_id=None): - super().__init__(timeout=None) - self.bot = bot - self.suggestion_id = suggestion_id - self.user_id = user_id - self.suggestion_text = suggestion_text - self.channel_id = channel_id - - approve_btn = discord.ui.Button( - label="Approve ✅", - style=discord.ButtonStyle.success, - custom_id=f"suggest_approve_{suggestion_id or 'dummy'}" - ) - approve_btn.callback = self.approve - self.add_item(approve_btn) - - deny_btn = discord.ui.Button( - label="Deny ❌", - style=discord.ButtonStyle.danger, - custom_id=f"suggest_deny_{suggestion_id or 'dummy'}" - ) - deny_btn.callback = self.deny - self.add_item(deny_btn) - - - async def approve(self, interaction: discord.Interaction): - if interaction.user.id != ADMIN_ID: - await interaction.response.send_message("❌ You can't approve suggestions.", ephemeral=True) - return - - if not self.suggestion_id: - await interaction.response.send_message("⚠️ This button is no longer active.", ephemeral=True) - return - - db_path = os.path.join(os.path.dirname(__file__), "suggestions.db") - async with aiosqlite.connect(db_path) as db: - await db.execute("UPDATE suggestions SET status = ? WHERE id = ?", ("Approved", self.suggestion_id)) - await db.commit() - - await interaction.response.send_message(f"✅ Suggestion #{self.suggestion_id} approved.", ephemeral=False) - - try: - user = await self.bot.fetch_user(self.user_id) - await user.send(f"✅ Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **approved!**") - except: - pass - - channel = self.bot.get_channel(self.channel_id) - if channel: - await channel.send(f"✅ Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been **approved!**") - - # Disable buttons after action - for item in self.children: - item.disabled = True - await interaction.message.edit(view=self) - - async def deny(self, interaction: discord.Interaction): - if interaction.user.id != ADMIN_ID: - await interaction.response.send_message("❌ You can't deny suggestions.", ephemeral=True) - return - - if not self.suggestion_id: - await interaction.response.send_message("⚠️ This button is no longer active.", ephemeral=True) - return - - db_path = os.path.join(os.path.dirname(__file__), "suggestions.db") - async with aiosqlite.connect(db_path) as db: - await db.execute("UPDATE suggestions SET status = ? WHERE id = ?", ("Denied", self.suggestion_id)) - await db.commit() - - await interaction.response.send_message(f"❌ Suggestion #{self.suggestion_id} denied.", ephemeral=False) - - try: - user = await self.bot.fetch_user(self.user_id) - await user.send(f"❌ Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **denied.**") - except: - pass - - channel = self.bot.get_channel(self.channel_id) - if channel: - await channel.send(f"❌ Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been **denied.**") - - # Disable buttons after action - for item in self.children: - item.disabled = True - await interaction.message.edit(view=self) + await interaction.response.send_message(embed=embed, ephemeral=False) async def setup(bot):