diff --git a/config/settings.json.example b/config/settings.json.example index 7dfae7be..ea3f9306 100644 --- a/config/settings.json.example +++ b/config/settings.json.example @@ -25,7 +25,8 @@ "REPORTS": 1235096305160814652, "GATE": 1235096247442870292, "DEV": 1235095919788167269, - "PRIVATE": 1235108340791513129 + "PRIVATE": 1235108340791513129, + "SUGGESTIONS": 1235096305160814652 }, "EMBED_COLORS": { "DEFAULT": 16044058, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39ab6074..dd57fe50 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,12 @@ enum CaseType { UNJAIL } +enum SuggestionStatus { + APPROVED + PENDING + DENIED +} + // 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 model Guild { @@ -45,6 +51,7 @@ model Guild { notes Note[] reminders Reminder[] guild_config GuildConfig[] + suggestions Suggestion[] @@unique([guild_id]) @@index([guild_id]) @@ -56,6 +63,7 @@ model GuildConfig { join_log_id BigInt? private_log_id BigInt? report_log_id BigInt? + suggestion_log_id BigInt? dev_log_id BigInt? jail_channel_id BigInt? general_channel_id BigInt? @@ -138,3 +146,17 @@ model Reminder { @@unique([reminder_id, guild_id]) @@index([reminder_id, guild_id]) } + +model Suggestion { + suggestion_id BigInt @id @default(autoincrement()) + suggestion_title String + suggestion_description String + suggestion_status SuggestionStatus @default(PENDING) + suggestion_review String? + suggestion_user_id BigInt + guild_id BigInt + guild Guild @relation(fields: [guild_id], references: [guild_id]) + + @@unique([suggestion_id, guild_id]) + @@index([suggestion_id, guild_id]) +} \ No newline at end of file diff --git a/tux/bot.py b/tux/bot.py index f705ae12..44aea05c 100644 --- a/tux/bot.py +++ b/tux/bot.py @@ -1,12 +1,14 @@ import asyncio from typing import Any +import discord from discord.ext import commands from loguru import logger from tux.cog_loader import CogLoader from tux.database.client import db from tux.help import TuxHelp +from tux.ui.modals.suggestion import ButtonView class Tux(commands.Bot): @@ -53,6 +55,8 @@ async def on_ready(self) -> None: if not self.setup_task.done(): await self.setup_task + self.add_view(ButtonView(embed=discord.Embed())) + @commands.Cog.listener() async def on_disconnect(self) -> None: """ diff --git a/tux/cogs/misc/suggestions.py b/tux/cogs/misc/suggestions.py new file mode 100644 index 00000000..a177c30f --- /dev/null +++ b/tux/cogs/misc/suggestions.py @@ -0,0 +1,30 @@ +import discord +from discord import app_commands +from discord.ext import commands + +from tux.ui.modals.suggestion import SuggestionModal + + +class Suggestion(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="suggest") + @app_commands.guild_only() + async def suggestion(self, interaction: discord.Interaction) -> None: + """ + Submit a suggestion for a server + + Parameters + ---------- + interaction : discord.Interaction + The interaction that triggered the command. + """ + + modal = SuggestionModal(bot=self.bot) + + await interaction.response.send_modal(modal) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Suggestion(bot)) diff --git a/tux/database/controllers/__init__.py b/tux/database/controllers/__init__.py index f096d65c..7a8fc5ad 100644 --- a/tux/database/controllers/__init__.py +++ b/tux/database/controllers/__init__.py @@ -4,6 +4,7 @@ from .note import NoteController from .reminder import ReminderController from .snippet import SnippetController +from .suggestion import SuggestionController class DatabaseController: @@ -14,3 +15,4 @@ def __init__(self): self.reminder = ReminderController() self.guild = GuildController() self.guild_config = GuildConfigController() + self.suggestion = SuggestionController() diff --git a/tux/database/controllers/guild_config.py b/tux/database/controllers/guild_config.py index 0db78499..bc6d876d 100644 --- a/tux/database/controllers/guild_config.py +++ b/tux/database/controllers/guild_config.py @@ -43,6 +43,7 @@ async def get_log_channel(self, guild_id: int, log_type: str) -> int | None: "join": "join_log_id", "private": "private_log_id", "report": "report_log_id", + "suggestion": "suggestion_log_id", "dev": "dev_log_id", } return await self.get_guild_config_field_value(guild_id, log_channel_ids[log_type]) @@ -116,7 +117,10 @@ async def get_private_log_id(self, guild_id: int) -> int | None: async def get_report_log_id(self, guild_id: int) -> int | None: return await self.get_guild_config_field_value(guild_id, "report_log_id") - async def get_dev_log_id(self, guild_id: int) -> int | None: + async def get_suggestion_log_channel(self, guild_id: int) -> int | None: + return await self.get_guild_config_field_value(guild_id, "suggestion_log_id") + + async def get_dev_log_channel(self, guild_id: int) -> int | None: return await self.get_guild_config_field_value(guild_id, "dev_log_id") async def get_jail_channel_id(self, guild_id: int) -> int | None: @@ -256,6 +260,23 @@ async def update_report_log_id( }, ) + async def update_suggestion_log_id( + self, + guild_id: int, + suggestion_log_id: int, + ) -> GuildConfig | None: + await self.ensure_guild_exists(guild_id) + return await self.table.upsert( + where={"guild_id": guild_id}, + data={ + "create": { + "guild_id": guild_id, + "suggestion_log_id": suggestion_log_id, + }, + "update": {"suggestion_log_id": suggestion_log_id}, + }, + ) + async def update_dev_log_id( self, guild_id: int, diff --git a/tux/database/controllers/suggestion.py b/tux/database/controllers/suggestion.py new file mode 100644 index 00000000..0a3cc7a0 --- /dev/null +++ b/tux/database/controllers/suggestion.py @@ -0,0 +1,48 @@ +from prisma.enums import SuggestionStatus +from prisma.models import Guild, Suggestion +from tux.database.client import db + + +class SuggestionController: + def __init__(self) -> None: + self.table = db.suggestion + self.guild_table = db.guild + + async def ensure_guild_exists(self, guild_id: int) -> Guild: + """ + Ensure a guild exists in the database and return the found or created object. + + Parameters + ---------- + guild_id : int + The ID of the guild to ensure exists. + + Returns + ------- + Guild + The guild database object. + """ + guild = await self.guild_table.find_first(where={"guild_id": guild_id}) + if guild is None: + return await self.guild_table.create(data={"guild_id": guild_id}) + return guild + + async def create_suggestion( + self, + suggestion_title: str, + suggestion_description: str, + suggestion_status: SuggestionStatus, + suggestion_user_id: int, + guild_id: int, + ) -> Suggestion: + await self.ensure_guild_exists(guild_id) + + return await self.table.create( + data={ + "suggestion_title": suggestion_title, + "suggestion_description": suggestion_description, + "suggestion_status": suggestion_status, + "suggestion_user_id": suggestion_user_id, + "guild_id": guild_id, + }, + ) diff --git a/tux/ui/modals/suggestion.py b/tux/ui/modals/suggestion.py new file mode 100644 index 00000000..a81951b8 --- /dev/null +++ b/tux/ui/modals/suggestion.py @@ -0,0 +1,132 @@ +import discord +from discord.ext import commands +from loguru import logger + +from tux.database.controllers import DatabaseController +from tux.utils import checks +from tux.utils.constants import Constants as CONST +from tux.utils.embeds import create_embed_footer + + +class ButtonView(discord.ui.View): + def __init__(self, embed: discord.Embed): + super().__init__(timeout=None) + self.embed = embed + + @discord.ui.button(label="Accept", style=discord.ButtonStyle.green, custom_id="accept_suggestion_button") + @checks.has_pl(5) + async def green_button(self, interaction: discord.Interaction, button: discord.ui.Button[discord.ui.View]): + self.embed.set_author(name="Suggestion Status: Accepted") + self.embed.add_field(name="Reviewed by", value=interaction.user.name, inline=False) + self.embed.color = discord.Color.green() + await interaction.response.edit_message(embed=self.embed, view=self) + + @discord.ui.button(label="Deny", style=discord.ButtonStyle.red, custom_id="deny_suggestion_button") + @checks.has_pl(5) + async def red_button(self, interaction: discord.Interaction, button: discord.ui.Button[discord.ui.View]): + self.embed.set_author(name="Suggestion Status: Rejected") + self.embed.add_field(name="Reviewed by", value=interaction.user.name, inline=False) + self.embed.color = discord.Color.red() + await interaction.response.edit_message(embed=self.embed, view=self) + + +class SuggestionModal(discord.ui.Modal): + def __init__(self, *, title: str = "Submit a suggestion", bot: commands.Bot) -> None: + super().__init__(title=title) + self.bot = bot + self.config = DatabaseController().guild_config + + suggestion_title = discord.ui.TextInput( # type: ignore + label="Suggestion Summary", + style=discord.TextStyle.short, + required=True, + max_length=100, + placeholder="Summarise your suggestion briefly", + ) + + suggestion_description = discord.ui.TextInput( # type: ignore + style=discord.TextStyle.long, + label="Suggestion Description", + required=True, + max_length=4000, + placeholder="Please provide as much detail as possible on your suggestion", + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + """ + Sends the suggestion to dedicated channel. + + Parameters + ---------- + interaction : discord.Interaction + The interaction that triggered the command. + """ + + if not interaction.guild: + logger.error("Guild is None") + return + + embed = discord.Embed( + title=self.suggestion_title.value, # type: ignore + description=self.suggestion_description.value, # type: ignore + color=CONST.EMBED_COLORS["DEFAULT"], + ) + embed.set_author(name="Suggestion Status: Under Review") + """ Commented until I finish the rest of the code + embed.add_field( + name="Review Info", + value="No review has been submitted", + inline=False, + ) + """ + footer_text, footer_icon_url = create_embed_footer(interaction=interaction) + embed.set_footer(text=footer_text, icon_url=footer_icon_url) + + try: + suggestion_log_channel_id = await self.config.get_suggestion_log_channel(interaction.guild.id) + except Exception as e: + logger.error(f"Failed to get suggestion log channel for guild {interaction.guild.id}. {e}") + await interaction.response.send_message( + "Failed to submit your suggestion. Please try again later.", + ephemeral=True, + delete_after=30, + ) + return + + if not suggestion_log_channel_id: + logger.error(f"Suggestion log channel not set for guild {interaction.guild.id}") + await interaction.response.send_message( + "The suggestion channel has not been set up. Please contact an administrator.", + ephemeral=True, + delete_after=30, + ) + return + + suggestion_log_channel = interaction.guild.get_channel(suggestion_log_channel_id) + if not suggestion_log_channel or not isinstance(suggestion_log_channel, discord.TextChannel): + logger.error(f"Failed to get suggestion log channel for guild {interaction.guild.id}") + await interaction.response.send_message( + "Failed to submit your suggestion. Please try again later.", + ephemeral=True, + delete_after=30, + ) + return + + await interaction.response.send_message( + "Your suggestion has been submitted.", + ephemeral=True, + delete_after=30, + ) + + view = ButtonView(embed=embed) + message = await suggestion_log_channel.send(embed=embed, view=view) + + await suggestion_log_channel.create_thread( + name=f"Suggestion: {self.suggestion_title.value}", # type: ignore + message=message, + auto_archive_duration=1440, + ) + + reactions = ["👍", "👎"] + for reaction in reactions: + await message.add_reaction(reaction) diff --git a/tux/ui/views/config.py b/tux/ui/views/config.py index 1847f3f8..f2d1dfd8 100644 --- a/tux/ui/views/config.py +++ b/tux/ui/views/config.py @@ -136,6 +136,26 @@ async def _set_join_log( delete_after=30, ) + @discord.ui.select( + cls=discord.ui.ChannelSelect, + channel_types=[discord.ChannelType.text], + placeholder="Set the suggestion channel.", + ) + async def _set_suggestion_log( + self, + interaction: discord.Interaction, + select: discord.ui.ChannelSelect[Any], + ) -> None: + if interaction.guild is None: + return + + await self.db.update_suggestion_log_id(interaction.guild.id, select.values[0].id) + await interaction.response.send_message( + f"Suggestion log channel set to {select.values[0]}.", + ephemeral=True, + delete_after=30, + ) + class ConfigSetChannels(discord.ui.View): def __init__(self, *, timeout: float = 180):