diff --git a/bot/cogs/poll.py b/bot/cogs/poll.py deleted file mode 100644 index 95feb86c..00000000 --- a/bot/cogs/poll.py +++ /dev/null @@ -1,151 +0,0 @@ -import discord -from discord.ext import commands - - -class Polls(commands.Cog): - def __init__(self, bot: commands.AutoShardedBot): - self.__bot = bot - - @property - def reactions(self): - return { - 1: "1️⃣", - 2: "2️⃣", - 3: "3️⃣", - 4: "4️⃣", - 5: "5️⃣", - 6: "6️⃣", - 7: "7️⃣", - 8: "8️⃣", - 9: "9️⃣", - 10: "🔟", - } - - def poll_check(self, message: discord.Message): - try: - embed = message.embeds[0] - except Exception: - return False - if str(embed.footer.text).count("Poll by") == 1: - return message.author == self.__bot.user - return False - - @commands.Cog.listener() - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - channel: discord.TextChannel = self.__bot.get_channel(payload.channel_id) - message: discord.Message = await channel.fetch_message(payload.message_id) - - if payload.user_id == self.__bot.user.id: - return - - if not self.poll_check(message): - return - - emojis = list(self.reactions.values()) - if str(payload.emoji) not in emojis: - return - - for reaction in message.reactions: - if str(reaction) not in emojis: - return - - if str(reaction.emoji) != str(payload.emoji): - user = self.__bot.get_user(payload.user_id) - await message.remove_reaction(reaction.emoji, user) - - @commands.group() - async def poll(self, ctx): - """Polls""" - if ctx.invoked_subcommand is None: - return await ctx.send_help(self.__bot.get_command("poll")) - - @poll.command() - @commands.cooldown(1, 10, commands.BucketType.channel) - async def new(self, ctx, desc: str, *choices): - """Create a new poll""" - await ctx.message.delete() - - if len(choices) < 2: - ctx.command.reset_cooldown(ctx) - if len(choices) == 1: - return await ctx.send("Can't make a poll with only one choice") - return await ctx.send("You have to enter two or more choices to make a poll") - - if len(choices) > 10: - ctx.command.reset_cooldown(ctx) - return await ctx.send("You can't make a poll with more than 10 choices") - - embed = discord.Embed( - description=f"**{desc}**\n\n" - + "\n\n".join(f"{str(self.reactions[i])} {choice}" for i, choice in enumerate(choices, 1)), - timestamp=discord.utils.utcnow(), - color=discord.colour.Color.gold(), - ) - embed.set_footer(text=f"Poll by {str(ctx.author)}") - msg = await ctx.send(embed=embed) - for i in range(1, len(choices) + 1): - await msg.add_reaction(self.reactions[i]) - - @poll.command() - async def show(self, ctx, message: str): - """Show a poll result""" - await ctx.message.delete() - - try: - *_, channel_id, msg_id = message.split("/") - - try: - channel = self.__bot.get_channel(int(channel_id)) - message = await channel.fetch_message(int(msg_id)) - except Exception: - return await ctx.send("Please provide the message ID/link for a valid poll") - except Exception: - try: - message = await ctx.channel.fetch_message(message) - except Exception: - return await ctx.send("Please provide the message ID/link for a valid poll") - - if self.poll_check(message): - poll_embed = message.embeds[0] - reactions = message.reactions - reactions_total = sum( - [reaction.count - 1 if str(reaction.emoji) in self.reactions.values() else 0 for reaction in reactions] - ) - - options = list( - map( - lambda o: " ".join(o.split()[1:]), - poll_embed.description.split("1️")[1].split("\n\n"), - ) - ) - desc = poll_embed.description.split("1️")[0] - - embed = discord.Embed( - description=desc, - timestamp=poll_embed.timestamp, - color=discord.Color.gold(), - ) - - for i, option in enumerate(options): - reaction_count = reactions[i].count - 1 - indicator = "░" * 20 - if reactions_total != 0: - indicator = "█" * int(((reaction_count / reactions_total) * 100) / 5) + "░" * int( - (((reactions_total - reaction_count) / reactions_total) * 100) / 5 - ) - - embed.add_field( - name=option, - value=f"{indicator} {int((reaction_count / (reactions_total or 1)*100))}%" - f" (**{reaction_count} votes**)", - inline=False, - ) - - embed.set_footer(text="Poll Result") - return await ctx.send(embed=embed) - - return await ctx.send("Please provide the message ID/link for a valid poll") - - -async def setup(bot): - await bot.add_cog(Polls(bot)) diff --git a/bot/core.py b/bot/core.py index 7b5eef2e..f3a14134 100644 --- a/bot/core.py +++ b/bot/core.py @@ -96,7 +96,8 @@ async def process_commands(self, message: discord.Message, /): async def on_app_command_error(self, interaction: "InteractionType", error: app_commands.AppCommandError): """Handle errors in app commands.""" - if isinstance(error, IgnorableException): + + if isinstance(error.__cause__, IgnorableException): return if interaction.command is None: diff --git a/bot/extensions/levelling/commands.py b/bot/extensions/levelling/commands.py index eae67cb3..60d0e0be 100644 --- a/bot/extensions/levelling/commands.py +++ b/bot/extensions/levelling/commands.py @@ -230,14 +230,13 @@ def convert_int(integer): xp_offset_x -= xp_text_size[2] - xp_text_size[0] draw.text((xp_offset_x, xp_offset_y), text, font=self.small_font, fill="#fff") - if len(username) >= 15: + if len(username) >= 18: # Truncating the name username = username[:15] + "..." text_bbox = draw.textbbox((0, 0), username, font=self.medium_font) - text_offset_x = bar_offset_x - 10 text_offset_y = bar_offset_y - (text_bbox[3] - text_bbox[1]) - 20 - draw.text((text_offset_x, text_offset_y), username, font=self.medium_font, fill="#fff") + draw.text((bar_offset_x, text_offset_y), username, font=self.medium_font, fill="#fff") # create copy of background background = self.background.copy() @@ -274,8 +273,6 @@ async def rank(self, interaction: core.InteractionType, member: discord.Member = record = await LevellingUser.pool.fetchrow(query, interaction.guild.id, member.id) - log.info(record) - if record.total_xp is None: if member.id == interaction.user.id: return await interaction.response.send_message( diff --git a/bot/extensions/polls/__init__.py b/bot/extensions/polls/__init__.py new file mode 100644 index 00000000..24223853 --- /dev/null +++ b/bot/extensions/polls/__init__.py @@ -0,0 +1,9 @@ +from bot.core import DiscordBot + +from .commands import Polls +from .events import PollEvents + + +async def setup(bot: DiscordBot) -> None: + await bot.add_cog(Polls(bot=bot)) + await bot.add_cog(PollEvents(bot=bot)) diff --git a/bot/extensions/polls/commands.py b/bot/extensions/polls/commands.py new file mode 100644 index 00000000..edeca6ad --- /dev/null +++ b/bot/extensions/polls/commands.py @@ -0,0 +1,76 @@ +import discord +from discord import app_commands +from discord.ext import commands + +from bot import core +from bot.extensions.polls.utils import emojis, poll_check +from bot.extensions.polls.views import CreatePollView +from utils.transformers import MessageTransformer + + +class Polls(commands.GroupCog, group_name="poll"): + def __init__(self, bot: core.DiscordBot): + self.bot = bot + + self._create_poll_view = CreatePollView(timeout=None) + self.bot.add_view(self._create_poll_view) + + @app_commands.command() + @app_commands.describe(question="Your question") + async def new(self, interaction: core.InteractionType, question: str): + """Create a new poll""" + + embed = discord.Embed( + description=f"**{question}**\n\n", + timestamp=discord.utils.utcnow(), + color=discord.colour.Color.gold(), + ) + embed.set_footer(text=f"Poll by {interaction.user.display_name}") + await interaction.response.send_message(embed=embed, ephemeral=True, view=self._create_poll_view) + + @app_commands.command() + async def show( + self, + interaction: core.InteractionType, + message: app_commands.Transform[discord.Message, MessageTransformer], + ephemeral: bool = True, + ): + """Show a poll result""" + + if not poll_check(message, self.bot.user): + return await interaction.response.send_message("Please provide a valid poll message", ephemeral=True) + + poll_embed = message.embeds[0] + reactions = message.reactions + reactions_total = sum([reaction.count - 1 if str(reaction.emoji) in emojis else 0 for reaction in reactions]) + + options = [field.name for field in poll_embed.fields] + desc = poll_embed.description.split("1️")[0] + + embed = discord.Embed( + description=desc, + timestamp=poll_embed.timestamp, + color=discord.Color.gold(), + ) + + for i, option in enumerate(options): + reaction_count = reactions[i].count - 1 + indicator = "░" * 20 + if reactions_total != 0: + indicator = "█" * int(((reaction_count / reactions_total) * 100) / 5) + "░" * int( + (((reactions_total - reaction_count) / reactions_total) * 100) / 5 + ) + + embed.add_field( + name=option, + value=f"{indicator} {int((reaction_count / (reactions_total or 1)*100))}%" + f" (**{reaction_count} votes**)", + inline=False, + ) + + embed.set_footer(text="Poll Result") + return await interaction.response.send_message(embed=embed, ephemeral=ephemeral) + + +async def setup(bot: core.DiscordBot): + await bot.add_cog(Polls(bot=bot)) diff --git a/bot/extensions/polls/events.py b/bot/extensions/polls/events.py new file mode 100644 index 00000000..220d641b --- /dev/null +++ b/bot/extensions/polls/events.py @@ -0,0 +1,34 @@ +import discord +from discord.ext import commands + +from bot import core +from bot.extensions.polls.utils import emojis, poll_check + + +class PollEvents(commands.Cog): + """Events for polls in discord.""" + + def __init__(self, bot: core.DiscordBot): + self.bot = bot + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + channel: discord.TextChannel = self.bot.get_channel(payload.channel_id) + message: discord.Message = await channel.fetch_message(payload.message_id) + + if payload.user_id == self.bot.user.id: + return + + if not poll_check(message, self.bot.user): + return + + if str(payload.emoji) not in emojis: + return + + for reaction in message.reactions: + if str(reaction) not in emojis: + return + + if str(reaction.emoji) != str(payload.emoji): + user = self.bot.get_user(payload.user_id) + await message.remove_reaction(reaction.emoji, user) diff --git a/bot/extensions/polls/utils.py b/bot/extensions/polls/utils.py new file mode 100644 index 00000000..e103145c --- /dev/null +++ b/bot/extensions/polls/utils.py @@ -0,0 +1,12 @@ +import discord + +emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"] + + +def poll_check(message: discord.Message, bot: discord.ClientUser): + if not message.embeds: + return False + + embed = message.embeds[0] + if str(embed.footer.text).count("Poll by") == 1: + return message.author == bot diff --git a/bot/extensions/polls/views.py b/bot/extensions/polls/views.py new file mode 100644 index 00000000..66434629 --- /dev/null +++ b/bot/extensions/polls/views.py @@ -0,0 +1,112 @@ +import typing as t + +import discord +from discord import ui + +from bot import core +from bot.extensions.polls.utils import emojis + +if t.TYPE_CHECKING: + from discord.embeds import _EmbedFieldProxy + + +class PollModal(ui.Modal, title="Add Choice"): + name = ui.TextInput(label="Choice name", placeholder="Enter poll choice", max_length=32, required=True) + description = ui.TextInput( + label="Choice description (optional)", + placeholder="Enter poll choice description", + style=discord.TextStyle.long, + max_length=512, + required=False, + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + embed = interaction.message.embeds[0] + field_count = len(embed.fields) + + embed.add_field(name=f"{str(emojis[field_count])} {self.name}", value=self.description, inline=False) + field_count += 1 + view = CreatePollView() + + add_choice_btn = discord.utils.get(view.children, custom_id=CreatePollView.ADD_CUSTOM_ID) + create_poll_btn = discord.utils.get(view.children, custom_id=CreatePollView.CREATE_CUSTOM_ID) + delete_select = discord.utils.find(lambda child: isinstance(child, discord.ui.Select), view.children) + + add_choice_btn.disabled = field_count > 9 + create_poll_btn.disabled = field_count < 2 + + view.remove_item(delete_select) + if field_count >= 1: + view.add_item(DeletePollOptions(embed.fields)) + + await interaction.response.edit_message(embed=embed, view=view) + + +class DeletePollOptions(discord.ui.Select): + def __init__(self, fields: list["_EmbedFieldProxy"]): + super().__init__( + row=2, + max_values=len(fields), + placeholder="➖ Select a choice to remove", + custom_id=CreatePollView.DELETE_CUSTOM_ID, + options=[ + discord.SelectOption(emoji=emojis[i], label=field.name.split(maxsplit=1)[1], value=str(i)) + for i, field in enumerate(fields) + ], + ) + + async def callback(self, interaction: core.InteractionType): + embed = interaction.message.embeds[0] + + for value in sorted(self.values, reverse=True): # to avoid conflict + embed.remove_field(int(value)) + + for i, field in enumerate(embed.fields): + embed.set_field_at( + i, name=f"{emojis[i]} {field.name.split(maxsplit=1)[1]}", value=field.value, inline=False + ) + + self.view.remove_item(self) + if len(embed.fields) >= 1: + self.view.add_item(DeletePollOptions(embed.fields)) + + # We removed a choice so there gotta be some space for more + add_choice_btn = discord.utils.get(self.view.children, custom_id=CreatePollView.ADD_CUSTOM_ID) + add_choice_btn.disabled = False + + await interaction.response.edit_message(embed=embed, view=self.view) + + +class CreatePollView(ui.View): + ADD_CUSTOM_ID = "extensions:polls:add" + DELETE_CUSTOM_ID = "extensions:polls:delete" + CREATE_CUSTOM_ID = "extensions:polls:create" + + @discord.ui.button(label="Add Choice", style=discord.ButtonStyle.blurple, emoji="➕", custom_id=ADD_CUSTOM_ID) + async def add_choice(self, interaction: core.InteractionType, _button: ui.Button): + embed = interaction.message.embeds[0] + field_count = len(embed.fields) + + if field_count >= 10: + return await interaction.response.send_message( + "You can't make a poll with more than 10 choices", ephemeral=True + ) + + await interaction.response.send_modal(PollModal()) + + @discord.ui.button( + label="Create Poll", style=discord.ButtonStyle.green, emoji="📝", custom_id=CREATE_CUSTOM_ID, disabled=True + ) + async def create_poll(self, interaction: core.InteractionType, _button: ui.Button): + embed = interaction.message.embeds[0] + + if len(embed.fields) < 2: + return await interaction.response.send_message( + "You can't create a poll with less than 2 choices", ephemeral=True + ) + + await interaction.response.send_message(embed=embed) + message = await interaction.original_response() + + for i in range(0, len(embed.fields)): + await message.add_reaction(emojis[i]) diff --git a/bot/extensions/tags/events.py b/bot/extensions/tags/events.py index 23059aba..23bfd048 100644 --- a/bot/extensions/tags/events.py +++ b/bot/extensions/tags/events.py @@ -13,7 +13,7 @@ class TagEvents(commands.Cog): def __init__(self, bot: core.DiscordBot): self.bot = bot - self._log_tag_creation_view = LogTagCreationView() + self._log_tag_creation_view = LogTagCreationView(timeout=None) self.bot.add_view(self._log_tag_creation_view) @property diff --git a/bot/extensions/tags/views.py b/bot/extensions/tags/views.py index 67c824d0..fc307d47 100644 --- a/bot/extensions/tags/views.py +++ b/bot/extensions/tags/views.py @@ -33,9 +33,6 @@ class LogTagCreationView(ui.View): DELETE_CUSTOM_ID = "extensions:tags:delete" FEATURE_CUSTOM_ID = "extensions:tags:feature" - def __init__(self, timeout: float = None): - super().__init__(timeout=timeout) - @staticmethod async def wait_for_confirmation(interaction: core.InteractionType, tag: Tag, reason: str): """If the tag name or content has changed, wait for confirmation that they really want to delete.""" diff --git a/bot/models/tag.py b/bot/models/tag.py index 85c5158e..8a168f55 100644 --- a/bot/models/tag.py +++ b/bot/models/tag.py @@ -19,8 +19,8 @@ class Tag(Model): @classmethod async def create(cls, guild_id: int, author_id: int, name: str, content: str) -> "Tag": query = """ - INSERT INTO tags (guild_id, author_id, name, content) - VALUES ($1, $2, $3, $4) + INSERT INTO tags (guild_id, author_id, name, content, uses) + VALUES ($1, $2, $3, $4, 0) RETURNING *; """ return await cls.fetchrow(query, guild_id, author_id, name, content) diff --git a/cli.py b/cli.py index f90ae121..7908687f 100644 --- a/cli.py +++ b/cli.py @@ -137,10 +137,10 @@ async def main(ctx): "bot.extensions.tags", "bot.extensions.levelling", "bot.extensions.persistent_roles", + "bot.extensions.polls", "bot.cogs._help", "bot.cogs.clashofcode", "bot.cogs.roles", - "bot.cogs.poll", ) intents = discord.Intents.all() diff --git a/utils/transformers.py b/utils/transformers.py index b16f5350..6558a717 100644 --- a/utils/transformers.py +++ b/utils/transformers.py @@ -13,14 +13,21 @@ async def transform(self, interaction: core.InteractionType, value: str, /): try: parts: list[str] = value.split("/") + # check that there are 2 parts + if len(parts) != 2: + return await interaction.channel.fetch_message(int(value)) + message_id = int(parts[-1]) channel_id = int(parts[-2]) channel = interaction.guild.get_channel(channel_id) return await channel.fetch_message(message_id) - except (ValueError, IndexError, AttributeError, discord.HTTPException): - await interaction.response.send_message("Sorry, I couldn't find that message...") - raise IgnorableException + except (ValueError, TypeError, IndexError, AttributeError): + await interaction.response.send_message("Please provide a valid message URL.", ephemeral=True) + except discord.HTTPException: + await interaction.response.send_message("Sorry, I couldn't find that message...", ephemeral=True) + + raise IgnorableException class CommandTransformer(app_commands.Transformer):