diff --git a/cogs/guild_config/views/embed/__init__.py b/cogs/guild_config/views/embed/__init__.py new file mode 100644 index 0000000..6f70d77 --- /dev/null +++ b/cogs/guild_config/views/embed/__init__.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Optional, TypeAlias, Self + +import discord +from discord import ButtonStyle + +from helpers.constants import EDIT_NICKNAME + +from .modals import ( + AddFieldModal, + EditAuthorModal, + EditEmbedModal, + EditFieldModal, + EditFooterModal, + EditWithModalButton, +) + +if TYPE_CHECKING: + from bot import DuckBot + + BotInteraction: TypeAlias = discord.Interaction[DuckBot] + + +class Embed(discord.Embed): + def __bool__(self) -> bool: + return any( + ( + self.title, + self.url, + self.description, + self.fields, + self.timestamp, + self.author, + self.thumbnail, + self.footer, + self.image, + ) + ) + + +class UndoView(discord.ui.View): + def __init__(self, parent: 'EmbedEditor'): + self.parent = parent + self.interaction_check = self.parent.interaction_check # type: ignore + super().__init__(timeout=10) + + @discord.ui.button(label='Undo deletion.') + async def undo(self, interaction: BotInteraction, button: discord.ui.Button[UndoView]): + self.stop() + await interaction.channel.send(view=self.parent, embed=self.parent.current_embed) # type: ignore + await interaction.response.edit_message(view=None) + await interaction.delete_original_response() + + async def on_timeout(self) -> None: + self.parent.stop() + + +class DeleteButton(discord.ui.Button['EmbedEditor']): + async def callback(self, interaction: BotInteraction): + if interaction.message: + await interaction.message.delete() + await interaction.response.send_message( + 'Done!\n*This message goes away in 10 seconds*\n*You can use this to recover your progress.*', + view=UndoView(self.view), # type: ignore + delete_after=10, + ephemeral=True, + ) + + +class FieldSelectorView(discord.ui.View): + def __init_subclass__(cls, label: str, **kwargs): + cls.label = label + super().__init_subclass__(**kwargs) + + def __init__(self, parent: EmbedEditor): + self.parent = parent + self.interaction_check = self.parent.interaction_check # type: ignore + super().__init__(timeout=300) + self.pick_field.placeholder = self.label + self.update_options() + + def update_options(self): + self.pick_field.options = [] + for i, field in enumerate(self.parent.embed.fields): + self.pick_field.add_option(label=f"{i + 1}) {(field.name or '')[0:95]}", value=str(i)) + + @discord.ui.select() + async def pick_field(self, interaction: BotInteraction, select: discord.ui.Select): + await self.actual_logic(interaction, select) + + @discord.ui.button(label='Go back') + async def cancel(self, interaction: BotInteraction, button: discord.ui.Button[Self]): + await interaction.response.edit_message(view=self.parent) + self.stop() + + async def actual_logic(self, interaction: BotInteraction, select: discord.ui.Select[Self]) -> None: + raise NotImplementedError('Child classes must overwrite this method.') + + +class DeleteFieldWithSelect(FieldSelectorView, label='Select a field to delete.'): + async def actual_logic(self, interaction: BotInteraction, select: discord.ui.Select[Self]): + index = int(select.values[0]) + self.parent.embed.remove_field(index) + await self.parent.update_buttons() + await interaction.response.edit_message(embed=self.parent.current_embed, view=self.parent) + self.stop() + + +class EditFieldSelect(FieldSelectorView, label='Select a field to edit.'): + async def actual_logic(self, interaction: BotInteraction, select: discord.ui.Select[Self]): + index = int(select.values[0]) + self.parent.timeout = 600 + await interaction.response.send_modal(EditFieldModal(self.parent, index)) + + +class SendToView(discord.ui.View): + def __init__(self, *, parent: EmbedEditor): + self.parent = parent + self.interaction_check = self.parent.interaction_check # type: ignore + super().__init__(timeout=300) + + async def send_to(self, interaction: BotInteraction, channel_id: int): + await interaction.response.defer(ephemeral=True) + if not isinstance(interaction.user, discord.Member) or not interaction.guild: + return await interaction.followup.send( + 'for some reason, discord thinks you are not a member of this server...', ephemeral=True + ) + + channel = interaction.guild.get_channel_or_thread(channel_id) + if not isinstance(channel, discord.abc.Messageable): + return await interaction.followup.send('That channel does not exist... somehow.', ephemeral=True) + if not channel.permissions_for(interaction.user).send_messages: + return await interaction.followup.send(f'You cannot send messages in {channel.mention}.', ephemeral=True) + if not channel.permissions_for(interaction.guild.me).send_messages: + return await interaction.followup.send(f'I cannot send messages in {channel.mention}.', ephemeral=True) + + await channel.send(embed=self.parent.embed) + await interaction.delete_original_response() + await interaction.followup.send('Sent!', ephemeral=True) + self.stop() + + @discord.ui.select( + cls=discord.ui.ChannelSelect, + channel_types=[ + discord.ChannelType.text, + discord.ChannelType.news, + discord.ChannelType.voice, + discord.ChannelType.private_thread, + discord.ChannelType.public_thread, + ], + placeholder="Pick a channel to send this embed to.", + ) + async def pick_a_channel(self, interaction: BotInteraction, select: discord.ui.ChannelSelect[SendToView]): + await self.send_to(interaction, select.values[0].id) + + @discord.ui.button(label="Send to current channel") + async def to_current_channel(self, interaction: BotInteraction, button: discord.ui.Button): + await self.send_to(interaction, interaction.channel.id if interaction.channel else 0) + + @discord.ui.button(label='Go Back') + async def stop_pages(self, interaction: BotInteraction, button: discord.ui.Button[SendToView]): + """stops the pagination session.""" + await interaction.response.edit_message(embed=self.parent.current_embed, view=self.parent) + self.stop() + + async def on_timeout(self) -> None: + if self.parent.message: + try: + await self.parent.message.edit(view=self.parent) + except discord.NotFound: + pass + + +class EmbedEditor(discord.ui.View): + def __init__(self, owner: discord.Member, *, timeout: Optional[float] = 600): + self.owner: discord.Member = owner + self.embed = Embed() + self.showing_help = False + self.message: Optional[discord.Message] = None + super().__init__(timeout=timeout) + self.clear_items() + self.add_items() + + @staticmethod + def shorten(_embed: discord.Embed): + embed = Embed.from_dict(deepcopy(_embed.to_dict())) + while len(embed) > 6000 and embed.fields: + embed.remove_field(-1) + if len(embed) > 6000 and embed.description: + embed.description = embed.description[: (len(embed.description) - (len(embed) - 6000))] + return embed + + @property + def current_embed(self) -> discord.Embed: + if self.showing_help: + return self.help_embed() + if self.embed: + if len(self.embed) < 6000: + return self.embed + else: + return self.shorten(self.embed) + return self.help_embed() + + async def interaction_check(self, interaction: BotInteraction, /): + if interaction.user == self.owner: + return True + await interaction.response.send_message('This is not your menu.', ephemeral=True) + + def add_items(self): + """This is done this way because if not, it would get too cluttered.""" + # Row 1 + self.add_item(discord.ui.Button(label='Edit:', style=ButtonStyle.blurple, disabled=True)) + self.add_item(EditWithModalButton(EditEmbedModal, label='Embed', style=ButtonStyle.blurple)) + self.add_item(EditWithModalButton(EditAuthorModal, row=0, label='Author', style=ButtonStyle.blurple)) + self.add_item(EditWithModalButton(EditFooterModal, row=0, label='Footer', style=ButtonStyle.blurple)) + # Row 2 + self.add_item(discord.ui.Button(row=1, label='Fields:', disabled=True, style=ButtonStyle.blurple)) + self.add_fields = EditWithModalButton(AddFieldModal, row=1, emoji='\N{HEAVY PLUS SIGN}', style=ButtonStyle.green) + self.add_item(self.add_fields) + self.add_item(self.remove_fields) + self.add_item(self.edit_fields) + # Row 3 + self.add_item(self.done) + self.add_item(DeleteButton(emoji='\N{WASTEBASKET}', style=ButtonStyle.red)) + self.add_item(self.help_page) + # Row 4 + self.character_count: discord.ui.Button[Self] = discord.ui.Button(row=3, label='0/6,000 Characters', disabled=True) + self.add_item(self.character_count) + self.fields_count: discord.ui.Button[Self] = discord.ui.Button(row=3, label='0/25 Total Fields', disabled=True) + self.add_item(self.fields_count) + + async def update_buttons(self): + fields = len(self.embed.fields) + self.add_fields.disabled = fields > 25 + self.remove_fields.disabled = not fields + self.edit_fields.disabled = not fields + self.help_page.disabled = not self.embed + if len(self.embed) <= 6000: + self.done.style = ButtonStyle.green + else: + self.done.style = ButtonStyle.red + + self.character_count.label = f"{len(self.embed)}/6,000 Characters" + self.fields_count.label = f"{len(self.embed.fields)}/25 Total Fields" + + if self.showing_help: + self.help_page.label = 'Show My Embed' + else: + self.help_page.label = 'Show Help Page' + + def help_embed(self) -> Embed: + embed = Embed( + title='How do I use this? [title]', + color=discord.Colour.blurple(), + description=( + "Use the below buttons to add things to the embed. " + "Once you are done, you will be able to send this to any channel " + "or begin configuring an event, which will enable this embed to " + "be sent to a selected channel when a condition is met." + "\n-# Note that some ***__discord formatting__*** features do NOT " + "in every field. Thanks discord!" + "\n-# Btw this \"main\" field is called the [description]. We use " + "[square brackets] in in this help page, so you can know the name of " + "each field." + ), + ) + embed.add_field( + name='This is a [field]. Specifically its [name].', + value=( + 'and this is the [value]. This field is [in-line]. You can have ' + 'up to **three** different fields in one line.' + ), + ) + embed.add_field( + name='Here is another [field]', value='As you can see, there are side by side as they both are [in-line].' + ) + embed.add_field( + name='Here is another field, but not in-line.', + value='Note that fields can have up to 256 characters in the name, and up to 1,024 characters in the value!', + inline=False, + ) + embed.add_field( + name='Placeholders for adding things to an event.', + value=( + 'If you plan to add this embed to an event, then there are some placeholders that are available. ' + 'They will be listed here once they are all completed.' # TODO + ), + ) + embed.set_author( + name='DuckBot Embed Creator! [author]', + icon_url='https://cdn.duck-bot.com/file/AVATAR', + ) + embed.set_image(url='https://cdn.duck-bot.com/file/IMAGE') + embed.set_thumbnail(url='https://cdn.duck-bot.com/file/THUMBNAIL') + footer_text = "This is the footer, which like the author, does not support markdown." + if not self.embed and not self.showing_help: + footer_text += '\n💢This embed will be replaced by yours once it has characters💢' + embed.set_footer(icon_url='https://cdn.duck-bot.com/file/ICON', text=footer_text) + return embed + + @discord.ui.button(row=1, emoji='\N{HEAVY MINUS SIGN}', style=ButtonStyle.red, disabled=True) + async def remove_fields(self, interaction: BotInteraction, button: discord.ui.Button[Self]): + await interaction.response.edit_message(view=DeleteFieldWithSelect(self)) + + @discord.ui.button(row=1, emoji=EDIT_NICKNAME, disabled=True, style=ButtonStyle.blurple) + async def edit_fields(self, interaction: BotInteraction, button: discord.ui.Button[Self]): + await interaction.response.edit_message(view=EditFieldSelect(self)) + + @discord.ui.button(label='Send To', row=2, style=ButtonStyle.red) + async def done(self, interaction: BotInteraction, button: discord.ui.Button[Self]): + if not self.embed: + return await interaction.response.send_message('Your embed is empty!', ephemeral=True) + elif len(self.embed) > 6000: + return await interaction.response.send_message( + 'You have exceeded the embed character limit (6000)', ephemeral=True + ) + await interaction.response.edit_message(view=SendToView(parent=self)) + + @discord.ui.button(label='Show Help Page', row=2, disabled=True) + async def help_page(self, interaction: BotInteraction, button: discord.ui.Button[Self]): + self.showing_help = not self.showing_help + await self.update_buttons() + await interaction.response.edit_message(embed=self.current_embed, view=self) + + async def on_timeout(self) -> None: + if self.message: + if self.embed: + await self.message.edit(view=None) + else: + await self.message.delete() diff --git a/cogs/guild_config/views/embed/modals.py b/cogs/guild_config/views/embed/modals.py new file mode 100644 index 0000000..c464087 --- /dev/null +++ b/cogs/guild_config/views/embed/modals.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Optional, Type, Union, Self + +import discord +from discord import ButtonStyle, Emoji, PartialEmoji + +if TYPE_CHECKING: + from . import EmbedEditor + + +URL_REGEX = re.compile('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+') + + +def to_boolean(argument: str) -> bool: + lowered = argument.lower() + if lowered in ('yes', 'y', 'true', 't', '1', 'on'): + return True + elif lowered in ('no', 'n', 'false', 'f', '0', 'off'): + return False + else: + raise InvalidModalField(f'{argument} is not a valid boolean value.') + + +class InvalidModalField(Exception): ... + + +class BaseModal(discord.ui.Modal): + def __init__(self, parent_view: EmbedEditor) -> None: + self.parent_view = parent_view + self.update_defaults(parent_view.embed) + super().__init__() + + def update_embed(self) -> None: + raise NotImplementedError + + def update_defaults(self, embed: discord.Embed): + return + + async def on_error(self, interaction: discord.Interaction, error: Exception, /) -> None: + if isinstance(error, InvalidModalField): + await self.parent_view.update_buttons() + await interaction.response.edit_message(embed=self.parent_view.current_embed, view=self.parent_view) + await interaction.followup.send(str(error), ephemeral=True) + return + await super().on_error(interaction, error) + + async def on_submit(self, interaction: discord.Interaction, /) -> None: + self.update_embed() + await self.parent_view.update_buttons() + await interaction.response.edit_message(embed=self.parent_view.current_embed, view=self.parent_view) + + +class EditWithModalButton(discord.ui.Button['EmbedEditor']): + def __init__( + self, + modal: Type[BaseModal], + /, + *, + style: ButtonStyle = ButtonStyle.secondary, + label: Optional[str] = None, + disabled: bool = False, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + row: Optional[int] = None, + ): + self.modal = modal + super().__init__(style=style, label=label, disabled=disabled, emoji=emoji, row=row) + + async def callback(self, interaction: discord.Interaction): + if not self.view: + raise discord.DiscordException('No view was found attached to this modal.') + await interaction.response.send_modal(self.modal(self.view)) + + +class EditEmbedModal(BaseModal, title='Editing the embed:'): + _title = discord.ui.TextInput[Self]( + label='Embed Title', placeholder='Leave any field empty to remove it', max_length=256, required=False + ) + description = discord.ui.TextInput[Self]( + label='Embed Description', + placeholder='Any text, up to 4,000 characters.\n\nEmbeds can have a shared total of 6,000 characters!', + style=discord.TextStyle.long, + required=False, + ) + image = discord.ui.TextInput[Self](label='Embed Image URL', placeholder='Must be HTTP(S) format.', required=False) + thumbnail = discord.ui.TextInput[Self]( + label='Thumbnail Image URL', placeholder='Must be HTTP(S) format.', required=False + ) + color = discord.ui.TextInput[Self]( + label='Embed Color', placeholder='Hex [#FFFFFF] or RGB [rgb(num, num, num)] only', required=False + ) + + def update_defaults(self, embed: discord.Embed): + self._title.default = embed.title + self.description.default = embed.description + self.image.default = embed.image.url + self.thumbnail.default = embed.thumbnail.url + if embed.color: + self.color.default = str(embed.color) + + def update_embed(self): + self.parent_view.embed.title = self._title.value.strip() or None + self.parent_view.embed.description = self.description.value.strip() or None + failed: list[str] = [] + if self.color.value: + try: + color = discord.Color.from_str(self.color.value) + self.parent_view.embed.color = color + except (ValueError, IndexError): + failed.append('Invalid Color given!') + else: + self.parent_view.embed.color = None + + sti = self.image.value.strip() + if URL_REGEX.fullmatch(sti): + self.parent_view.embed.set_image(url=sti) + elif sti: + failed.append('Image URL did not match the http/https format') + else: + self.parent_view.embed.set_image(url=None) + + sti = self.thumbnail.value.strip() + if URL_REGEX.fullmatch(sti): + self.parent_view.embed.set_thumbnail(url=sti) + elif sti: + failed.append('Thumbnail URL did not match the http/https format') + else: + self.parent_view.embed.set_thumbnail(url=None) + if failed: + raise InvalidModalField('\n'.join(failed)) + + +class EditAuthorModal(BaseModal, title='Editing the embed author:'): + name = discord.ui.TextInput[Self]( + label='Author name', max_length=256, placeholder='Leave any field empty to remove it', required=False + ) + url = discord.ui.TextInput[Self](label="Author URL", placeholder='Must be HTTP(S) format.', required=False) + image = discord.ui.TextInput[Self](label='Author Icon URL', placeholder='Must be HTTP(S) format.', required=False) + + def update_defaults(self, embed: discord.Embed): + self.name.default = embed.author.name + self.url.default = embed.author.url + self.image.default = embed.author.icon_url + + def update_embed(self): + author = self.name.value.strip() + if not author: + self.parent_view.embed.remove_author() + + failed: list[str] = [] + + image_url = None + sti = self.image.value.strip() + if URL_REGEX.fullmatch(sti): + if not author: + failed.append( + 'Cannot add image. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + image_url = sti + elif sti: + if not author: + failed.append( + 'Cannot add url. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + failed.append('Image URL did not match the http/https format.') + + url = None + sti = self.url.value.strip() + if URL_REGEX.fullmatch(sti): + if not author: + failed.append( + 'Cannot add url. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + url = sti + elif sti: + if not author: + failed.append( + 'Cannot add url. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + failed.append('URL did not match the http/https format.') + + if author: + self.parent_view.embed.set_author(name=author, url=url, icon_url=image_url) + + if failed: + raise InvalidModalField('\n'.join(failed)) + + +class EditFooterModal(BaseModal, title='Editing the embed author:'): + text = discord.ui.TextInput[Self]( + label='Footer text', max_length=256, placeholder='Leave any field empty to remove it', required=False + ) + image = discord.ui.TextInput[Self](label='Footer icon URL', placeholder='Must be HTTP(S) format.', required=False) + + def update_defaults(self, embed: discord.Embed): + self.text.default = embed.footer.text + self.image.default = embed.footer.icon_url + + def update_embed(self): + text = self.text.value.strip() + if not text: + self.parent_view.embed.remove_author() + + failed: list[str] = [] + + image_url = None + sti = self.image.value.strip() + if URL_REGEX.fullmatch(sti): + if not text: + failed.append( + 'Cannot add image. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + image_url = sti + elif sti: + if not text: + failed.append( + 'Cannot add url. NAME is required to add an author.\n(Leave all fields empty to remove author.)' + ) + failed.append('Image URL did not match the http/https format.') + + if text: + self.parent_view.embed.set_footer(text=text, icon_url=image_url) + + if failed: + raise InvalidModalField('\n'.join(failed)) + + +class AddFieldModal(BaseModal, title='Add a field'): + name = discord.ui.TextInput[Self](label='Field Name', max_length=256) + value = discord.ui.TextInput[Self](label='Field Value', max_length=1024, style=discord.TextStyle.paragraph) + inline = discord.ui.TextInput[Self]( + label='Is inline?', placeholder='[ "Yes" | "No" ] (Default: Yes)', max_length=4, required=False + ) + index = discord.ui.TextInput[Self]( + label='Index (where to place this field)', + placeholder='Number between 1 and 25. Default: 25 (last)', + max_length=2, + required=False, + ) + + def update_embed(self): + failed: list[str] = [] + + name = self.name.value.strip() + if not name: + raise InvalidModalField('Name and Value are required.') + value = self.value.value.strip() + if not value: + raise InvalidModalField('Name and Value are required.') + _inline = self.inline.value.strip() + _idx = self.index.value.strip() + + inline = True + if _inline: + try: + inline = to_boolean(_inline) + except Exception as e: + failed.append(str(e)) + + if _idx: + try: + index = int(_idx) - 1 + self.parent_view.embed.insert_field_at(index=index, name=name, value=value, inline=inline) + except: + failed.append('Invalid index! (not a number)') + self.parent_view.embed.add_field(name=name, value=value, inline=inline) + else: + self.parent_view.embed.add_field(name=name, value=value, inline=inline) + + if failed: + raise InvalidModalField('\n'.join(failed)) + + +class EditFieldModal(BaseModal): + name = discord.ui.TextInput[Self](label='Field Name', max_length=256) + value = discord.ui.TextInput[Self](label='Field Value', max_length=1024, style=discord.TextStyle.paragraph) + inline = discord.ui.TextInput[Self]( + label='Is inline?', placeholder='[ "Yes" | "No" ] (Default: Yes)', max_length=4, required=False + ) + new_index = discord.ui.TextInput[Self]( + label='Index (where to place this field)', + placeholder='Number between 1 and 25. Default: 25 (last)', + max_length=2, + required=False, + ) + + def __init__(self, parent_view: EmbedEditor, index: int) -> None: + self.field = parent_view.embed.fields[index] + self.title = f'Editing field number {index}' + self.index = index + + super().__init__(parent_view) + + def update_defaults(self, embed: discord.Embed): + self.name.default = self.field.name + self.value.default = self.field.value + self.inline.default = 'Yes' if self.field.inline else 'No' + self.new_index.default = str(self.index + 1) + + def update_embed(self): + failed = None + + name = self.name.value.strip() + if not name: + raise InvalidModalField('Name and Value are required.') + value = self.value.value.strip() + if not value: + raise InvalidModalField('Name and Value are required.') + _inline = self.inline.value.strip() + + inline = True + if _inline: + try: + inline = to_boolean(_inline) + except Exception as e: + failed = str(e) + if self.new_index.value.isdigit(): + self.parent_view.embed.remove_field(self.index) + self.parent_view.embed.insert_field_at(int(self.new_index.value) - 1, name=name, value=value, inline=inline) + else: + self.parent_view.embed.set_field_at(self.index, name=name, value=value, inline=inline) + + if failed: + raise InvalidModalField(failed) diff --git a/cogs/guild_config/views/events.py b/cogs/guild_config/views/events.py new file mode 100644 index 0000000..5673e63 --- /dev/null +++ b/cogs/guild_config/views/events.py @@ -0,0 +1,11 @@ +import discord + + +class ModifyEvent(discord.ui.View): + @discord.ui.button(label="Edit Content") + async def edit_content(self, interaction: discord.Interaction, button: discord.ui.Button): + pass + + @discord.ui.button() + async def edit_embed(self, interaction: discord.Interaction, button: discord.ui.Button): + pass diff --git a/cogs/guild_config/welcome.py b/cogs/guild_config/welcome.py index 83ee660..e1705b8 100644 --- a/cogs/guild_config/welcome.py +++ b/cogs/guild_config/welcome.py @@ -1,26 +1,24 @@ from __future__ import annotations -import random -import typing +from typing import Union, TYPE_CHECKING from logging import getLogger -from types import SimpleNamespace -from typing import TYPE_CHECKING, Annotated import discord +from discord import app_commands from discord.ext import commands import errors -from bot import CustomContext from helpers.time_formats import human_join from ._base import ConfigBase +from .views.events import CreateEvent + if TYPE_CHECKING: - clean_content = str -else: - from discord.ext.commands import clean_content + from bot import DuckBot + -default_message = "**{inviter}** just added **{user}** to **{server}** (They're the **{count}** to join)" +default_message = "***{user}** just joined **{server}**! Welcome." log = getLogger(__name__) @@ -46,10 +44,11 @@ class SilentError(errors.NoHideout): pass -Q_T = typing.Union[str, discord.Embed] +Q_T = Union[str, discord.Embed] class ValidPlaceholdersConverter(commands.Converter): + async def convert(self, ctx: commands.Context, argument: str) -> str: if len(argument) > 1000: raise commands.BadArgument(f"That welcome message is too long! ({len(argument)}/1000)") @@ -79,221 +78,35 @@ async def convert(self, ctx: commands.Context, argument: str) -> str: class Welcome(ConfigBase): - async def prompt(self, ctx: CustomContext, question: Q_T, *, timeout: int = 60) -> str: - try: - return await ctx.prompt(question, timeout=timeout, delete_after=None) - except commands.UserInputError: - raise SilentError - - async def prompt_converter( - self, - ctx: CustomContext, - question: Q_T, - retry_question: Q_T = None, - converter: commands.Converter | typing.Any = None, - timeout: int = 60, - ): - """Prompts the user for something""" - if retry_question and not converter: - raise ValueError("You must provide a converter if you want to use a retry question") - - answer = await self.prompt(ctx, question, timeout=timeout) - if answer.casefold() == 'cancel': - raise SilentError - - if not retry_question: - if converter: - answer = await converter.convert(ctx, answer) - return answer - else: - ret_error = None - - while True: - if answer.casefold() == 'cancel': - raise SilentError - try: - answer = await converter.convert(ctx, answer) - return answer - except commands.UserInputError as e: - ret_error = e - - if ret_error: - prompt = f"{ret_error}\n{retry_question}" - else: - prompt = retry_question - - answer = await self.prompt(ctx, prompt, timeout=timeout) - - @commands.group(invoke_without_command=True) - @commands.has_permissions(manage_guild=True) - @commands.guild_only() - async def welcome(self, ctx: CustomContext): - """ - Commands to manage the welcome message for this server. - """ - text = await self.prompt(ctx, "Would you like to set up welcome messages? (y/n) ") - if text.casefold() not in ("y", "yes", "n", "no"): - raise commands.BadArgument("Please enter either `y`, `yes`, `n` or `no`") - if text.casefold() in ("n", "no"): - raise commands.BadArgument("Alright! I'll stop now.") - - question = "Where would you like me to send the welcome message to? Please mention a channel / ID" - retry_question = "That's not a valid channel / ID. Please try again or say `cancel` to cancel" - channel: discord.TextChannel = await self.prompt_converter( - ctx, question, retry_question=retry_question, converter=commands.TextChannelConverter() - ) # type: ignore - embed = discord.Embed( - title="What do you want the welcome message to say?", - description="**__Here are all available placeholders__**\n" - "To use these placeholders, surround them in `{}`. For example: {user-mention}\n\n" - "> **`server`** : returns the server's name (Server Name)\n" - "> **`user`** : returns the user's name (Name)\n" - "> **`full-user`** : returns the user's full name (Name#1234)\n" - "> **`user-mention`** : will mention the user (@Name)\n" - "> **`count`** : returns the member count of the server(4385)\n" - "> **`ordinal`** : returns the ordinal member count of the server(4385th)\n" - "> **`code`** : the invite code the member used to join(TdRfGKg8Wh) **\\***\n" - "> **`full-code`** : the full invite (discord.gg/TdRfGKg8Wh) **\\***\n" - "> **`full-url`** : the full url () **\\***\n" - "> **`inviter`** : returns the inviters name (Name) *****\n" - "> **`full-inviter`** : returns the inviters full name (Name#1234) **\\***\n" - "> **`inviter-mention`** : returns the inviters mention (@Name) **\\***\n\n" - "⚠ These placeholders are __CASE SENSITIVE.__\n" - "⚠ Placeholders marked with **\\*** may not be populated when a member joins, " - "like when a bot joins, or when a user is added by an integration.\n", - ) - embed.set_footer(text="If you want to cancel, say cancel") - r_q = 'Sorry but there was an invalid placeholdewelcomer. Please try again or say `cancel` to cancel' - message = await self.prompt_converter( - ctx, question=embed, retry_question=r_q, converter=ValidPlaceholdersConverter() - ) - query = """ INSERT INTO guilds(guild_id, welcome_channel, welcome_message) VALUES ($1, $2, $3) - ON CONFLICT (guild_id) DO UPDATE SET welcome_channel = $2, welcome_message=$3 """ - await self.bot.db.execute(query, ctx.guild.id, channel.id, message) - - member = ctx.author - invite = SimpleNamespace( - url='https://discord.gg/TdRfGKg8Wh', code='TdRfGKg8Wh', inviter=random.choice(ctx.guild.members) - ) - - to_format = { - 'server': str(ctx.guild), - 'user': str(member.display_name), - 'full-user': str(member), - 'user-mention': str(member.mention), - 'count': str(ctx.guild.member_count), - 'ordinal': str(make_ordinal(ctx.guild.member_count)), - 'code': str(invite.code), - 'full-code': f"discord.gg/{invite.code}", - 'full-url': str(invite.url), - 'inviter': invite.inviter.display_name, - 'full-inviter': str(invite.inviter), - 'inviter-mention': invite.inviter.mention, - } - - embed = discord.Embed(title='Set welcome messages.') - embed.add_field(name='Sending to:', value=channel.mention, inline=False) - embed.add_field(name='Welcome Message:', value=message, inline=False) - embed.add_field(name='Message Preview:', value=message.format(**to_format), inline=False) - - await ctx.send(embed=embed, footer=False) - - @commands.has_permissions(manage_guild=True) - @commands.guild_only() - @welcome.command(name='channel') - async def welcome_channel(self, ctx: CustomContext, *, new_channel: discord.TextChannel = None): # type: ignore - """ - Sets the channel where the welcome messages should be delivered to. - Send it without the channel - """ - channel = new_channel - query = """ INSERT INTO guilds(guild_id, welcome_channel) VALUES ($1, $2) - ON CONFLICT (guild_id) DO UPDATE SET welcome_channel = $2 """ - if channel: - if not channel.permissions_for(ctx.author).send_messages: - raise commands.BadArgument("You can't send messages in that channel!") - await self.bot.db.execute(query, ctx.guild.id, channel.id) - self.bot.welcome_channels[ctx.guild.id] = channel.id - message = await self.bot.db.fetchval("SELECT welcome_message FROM guilds WHERE guild_id = $1", ctx.guild.id) - await ctx.send( - f"Done! Welcome channel updated to {channel.mention} \n" - f"{'also, you can customize the welcome message with the `welcome message` command.' if not message else ''}" + @commands.command() + async def welcome(self, ctx): + await ctx.send("Welcome messages are now managed via `/events`") + + event = app_commands.Group(name='event') + + @event.command(name='create') + @app_commands.choices( + when=[ + # Different events that may happen in a server + app_commands.Choice(name="user joins server", value=0), + app_commands.Choice(name="user leaves server", value=1), + app_commands.Choice(name="user obtains role or roles", value=2), + app_commands.Choice(name="user loses role or roles", value=3), + app_commands.Choice(name="other event", value=-1), + ] + ) + async def event_create(self, interaction: discord.Interaction[DuckBot], when: app_commands.Choice[int]): + if when.value == -1: + await interaction.response.send_message( + "Didn't find an event you like? Join our support server, which you can " + "find in the help command, and create a post in the suggestions channel." + "\n-# If you can't find the channel, [click here](https://discord.com/" + "channels/774561547930304536/1077034825229291590) after joining the server.", + ephemeral=True, ) - else: - await self.bot.db.execute(query, ctx.guild.id, None) - self.bot.welcome_channels[ctx.guild.id] = None - await ctx.send("Done! cleared the welcome channel.") - - @commands.has_permissions(manage_guild=True) - @commands.guild_only() - @welcome.command(name="message") - async def welcome_message(self, ctx: CustomContext, *, message: Annotated[str, clean_content]): - """ - Sets the welcome message for this server. - - **__Here are all available placeholders__** - To use these placeholders, surround them in `{}`. For example: {user-mention} - - > **`server`** : returns the server's name (Server Name) - > **`user`** : returns the user's name (Name) - > **`full-user`** : returns the user's full name (Name#1234) - > **`user-mention`** : will mention the user (@Name) - > **`count`** : returns the member count of the server(4385) - > **`ordinal`** : returns the ordinal member count of the server(4385th) - > **`code`** : the invite code the member used to join(TdRfGKg8Wh) **\\*** - > **`full-code`** : the full invite (discord.gg/TdRfGKg8Wh) **\\*** - > **`full-url`** : the full url () **\\*** - > **`inviter`** : returns the inviters name (Name) ***** - > **`full-inviter`** : returns the inviters full name (Name#1234) **\\*** - > **`inviter-mention`** : returns the inviters mention (@Name) **\\*** - - ⚠ These placeholders are __CASE SENSITIVE.__ - ⚠ Placeholders marked with ***** may not be populated when a member joins, like when a bot joins, or when a user is added by an integration. - - **🧐 Example:** - `%PRE%welcome message Welcome to **{server}**, **{full-user}**!` - **📤 Output when a user joins:** - > Welcome to **Duck Hideout**, **LeoCx1000#9999**! - """ - query = """ - INSERT INTO guilds(guild_id, welcome_message) VALUES ($1, $2) - ON CONFLICT (guild_id) DO UPDATE SET welcome_message = $2 - """ - - await ValidPlaceholdersConverter().convert(ctx, message) - - await self.bot.db.execute(query, ctx.guild.id, message) - - return await ctx.send(f"**Welcome message updated to:**\n{message}") - - @commands.has_permissions(manage_guild=True) - @commands.guild_only() - @welcome.command(name='fake-message', aliases=['fake', 'test-message']) - async def welcome_message_test(self, ctx: CustomContext): - """Sends a fake welcome message to test the one set using the `welcome message` command.""" - member = ctx.author - message = await self.bot.db.fetchval("SELECT welcome_message FROM guilds WHERE guild_id = $1", ctx.guild.id) - message = message or default_message - invite = SimpleNamespace( - url='https://discord.gg/TdRfGKg8Wh', code='TdRfGKg8Wh', inviter=random.choice(ctx.guild.members) - ) - - to_format = { - 'server': str(ctx.guild), - 'user': str(member.display_name), - 'full-user': str(member), - 'user-mention': str(member.mention), - 'count': str(ctx.guild.member_count), - 'ordinal': str(make_ordinal(ctx.guild.member_count)), - 'code': str(invite.code), - 'full-code': f"discord.gg/{invite.code}", - 'full-url': str(invite.url), - 'inviter': invite.inviter.display_name, - 'full-inviter': str(invite.inviter), - 'inviter-mention': invite.inviter.mention, - } - await ctx.send(message.format(**to_format), allowed_mentions=discord.AllowedMentions.none()) + @event.command(name='create') + async def event_edit(self, interaction: discord.Interaction[DuckBot]): ... @commands.Cog.listener() async def on_invite_update(self, member: discord.Member, invite: discord.Invite | None): diff --git a/cogs/hideout.py b/cogs/hideout.py index 5947801..d9fedcb 100644 --- a/cogs/hideout.py +++ b/cogs/hideout.py @@ -338,7 +338,7 @@ async def raw_message(self, ctx: CustomContext, message: typing.Optional[discord data = await self.bot.http.get_message(message.channel.id, message.id) except discord.HTTPException: raise commands.BadArgument("There was an error retrieving that message.") - pretty_data = json.dumps(data, indent=4) + pretty_data = json.dumps(data, indent=4).replace('`', '\\u0060') return await ctx.send(f"```json\n{pretty_data}\n```", reference=ctx.message, gist=True, extension='json') @commands.Cog.listener() diff --git a/helpers/context.py b/helpers/context.py index 9af1901..29d8340 100644 --- a/helpers/context.py +++ b/helpers/context.py @@ -413,26 +413,25 @@ async def prompt( raise commands.BadArgument("Prompt timed out.") except Exception as e: logging.error(f"Failed to prompt user for input", exc_info=e) - message = None else: - if message and message.content.lower() == "cancel": + if message.content.lower() == "cancel": raise commands.BadArgument("✅ Cancelled!") if message and not return_message: return message.content else: return message + finally: - if delete_after is None: - to_do = [] - if isinstance(usermessage, discord.Message): - if delete_after: - to_do.append(bot_message.delete()) - if message and self.channel.permissions_for(self.me).manage_messages: - to_do.append(usermessage.delete()) - else: - to_do.append(usermessage.add_reaction(random.choice(self.bot.constants.DONE))) + to_do = [] + if isinstance(usermessage, discord.Message): + if delete_after: + to_do.append(bot_message.delete()) + if message and self.channel.permissions_for(self.me).manage_messages: + to_do.append(usermessage.delete()) else: to_do.append(usermessage.add_reaction(random.choice(self.bot.constants.DONE))) + else: + to_do.append(usermessage.add_reaction(random.choice(self.bot.constants.DONE))) - [self.bot.loop.create_task(to_do_item) for to_do_item in to_do] + [self.bot.loop.create_task(to_do_item) for to_do_item in to_do] diff --git a/scheme.sql b/scheme.sql new file mode 100644 index 0000000..5dee6a7 --- /dev/null +++ b/scheme.sql @@ -0,0 +1,17 @@ + + +CREATE TABLE event_notifications ( + event_id SERIAL PRIMARY KEY, + guild_id BIGINT REFERENCES guilds(guild_id) ON DELETE CASCADE, + channel_id BIGINT, + content TEXT, + embed JSONB, + condition INT, + condition_data JSONB +); + +CREATE TABLE user_notification_data ( + user_id BIGINT, + event_id BIGINT REFERENCES event_notifications(event_id) ON DELETE CASCADE, + condition_data JSONB +); \ No newline at end of file