diff --git a/README.md b/README.md index 49d3d05..7636788 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ | melonx | `help`, `transfer`, `mods`, `gamecrash`, `requirements`, `error`, `26` | | miscellaneous | `keanu`, `labubu`, `piracy`, `tryitandsee`, `rickroll`, `dontasktoask`, `support`, `depart`| | utilities | `translate`, `codepreview`, `dictionary` | -| media | `download`, `mcquote`, `img2gif` | +| media | `download`, `mcquote`, `img2gif`, `tweety` | ## Download diff --git a/TODO.md b/TODO.md index 66733cb..c317ff5 100644 --- a/TODO.md +++ b/TODO.md @@ -27,7 +27,7 @@ - [ ] Create github action -- [ ] Add video commands +- [x] Add video commands - [ ] Add git log to info diff --git a/cogs/media/__init__.py b/cogs/media/__init__.py index 11721aa..c3acee5 100644 --- a/cogs/media/__init__.py +++ b/cogs/media/__init__.py @@ -6,6 +6,7 @@ from typing import Optional from .download import download_command from .mcquote import mcquote_command from .img2gif import img2gif_command +from .tweety import tweety_command def _require_group_prefix(context: Context) -> bool: @@ -23,6 +24,20 @@ class Media(commands.GroupCog, name="media"): self.bot = bot super().__init__() + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + """Listen for bot mentions with 'tweety' command while replying to a message""" + if message.author.bot: + return + + if self.bot.user in message.mentions and message.reference and message.reference.message_id: + content = message.content.lower() + content_without_mention = content.replace(f'<@{self.bot.user.id}>', '').replace(f'<@!{self.bot.user.id}>', '').strip() + + if content_without_mention.strip() == 'tweety': + ctx = await self.bot.get_context(message) + await self.tweety(ctx) + @commands.group(name="media", invoke_without_command=True) async def media_group(self, context: Context): embed = discord.Embed( @@ -31,7 +46,7 @@ class Media(commands.GroupCog, name="media"): color=0x7289DA ) embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") - embed.add_field(name="Available", value="download, mcquote, img2gif", inline=False) + embed.add_field(name="Available", value="download, mcquote, img2gif, tweety", inline=False) await context.send(embed=embed) async def _invoke_hybrid(self, context: Context, name: str, *args, **kwargs): @@ -44,6 +59,9 @@ class Media(commands.GroupCog, name="media"): if name == "img2gif": await self.img2gif(context, attachment=kwargs.get('attachment')) return + if name == "tweety": + await self.tweety(context) + return await context.send(f"Unknown media command: {name}") @media_group.command(name="download") @@ -58,6 +76,10 @@ class Media(commands.GroupCog, name="media"): async def media_group_img2gif(self, context: Context, attachment: Optional[discord.Attachment] = None): await self._invoke_hybrid(context, "img2gif", attachment=attachment) + @media_group.command(name="tweety") + async def media_group_tweety(self, context: Context): + await self._invoke_hybrid(context, "tweety") + @commands.check(_require_group_prefix) @commands.hybrid_command( name="download", @@ -82,6 +104,14 @@ class Media(commands.GroupCog, name="media"): async def img2gif(self, context, attachment: Optional[discord.Attachment] = None): return await img2gif_command()(self, context, attachment=attachment) + @commands.check(_require_group_prefix) + @commands.hybrid_command( + name="tweety", + description="Convert a replied message to a tweet image.", + ) + async def tweety(self, context): + return await tweety_command()(self, context) + async def setup(bot) -> None: cog = Media(bot) await bot.add_cog(cog) @@ -89,3 +119,4 @@ async def setup(bot) -> None: bot.logger.info("Loaded extension 'media.download'") bot.logger.info("Loaded extension 'media.mcquote'") bot.logger.info("Loaded extension 'media.img2gif'") + bot.logger.info("Loaded extension 'media.tweety'") diff --git a/cogs/media/tweety.py b/cogs/media/tweety.py new file mode 100644 index 0000000..cbab0c8 --- /dev/null +++ b/cogs/media/tweety.py @@ -0,0 +1,419 @@ +# The API used in the tweety command is made by me and can be found here: +# https://github.com/neoarz/tweety-api +# I made this out of spite since i couldnt find any free APIs for this +# Its serverless and hosted on Vercel :) + +import os +import tempfile +import discord +from discord.ext import commands +import aiohttp +from datetime import datetime +from typing import Optional +import pytz + + +def break_long_words(text: str, max_word_length: int = 50) -> str: + words = text.split(' ') + result = [] + + for word in words: + if len(word) > max_word_length: + chunks = [word[i:i+max_word_length] for i in range(0, len(word), max_word_length)] + result.append(' '.join(chunks)) + else: + result.append(word) + + return ' '.join(result) + + +class TweetyHelpView(discord.ui.View): + """This is the help view for the slash command cuz only the pinging and prefix versions can use this command""" + + def __init__(self, user_id: int, bot): + super().__init__(timeout=180) + self.user_id = user_id + self.bot = bot + self.current_page = 0 + self.pages = [ + { + "title": "Tweety (Method 1)", + "description": "Use the prefix command `.media tweety` while replying to a message.", + "gif_url": "https://yes.nighty.works/raw/VrKX1L.gif", + "fields": [ + {"name": "How to use", "value": "1. Reply to any message\n2. Type `.media tweety`\n3. Use the buttons to customize!", "inline": False}, + ] + }, + { + "title": "Tweety (Method 2)", + "description": f"Mention <@{bot.user.id}> with `tweety` while replying to a message.", + "gif_url": "https://yes.nighty.works/raw/9XEe9j.gif", + "fields": [ + {"name": "How to use", "value": f"1. Reply to any message\n2. Type `<@{bot.user.id}> tweety`\n3. Use the buttons to customize!", "inline": False}, + ] + } + ] + self.update_buttons() + + def create_embed(self): + page = self.pages[self.current_page] + embed = discord.Embed( + title=page["title"], + description=page["description"], + color=0x7289DA, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + + for field in page["fields"]: + embed.add_field(name=field["name"], value=field["value"], inline=field["inline"]) + + embed.set_image(url=page["gif_url"]) + embed.set_footer(text=f"Page {self.current_page + 1}/{len(self.pages)}") + + return embed + + def update_buttons(self): + self.clear_items() + + prev_button = discord.ui.Button( + label="Prev", + style=discord.ButtonStyle.secondary, + emoji=discord.PartialEmoji(name="left", id=1420240344926126090), + disabled=self.current_page == 0 + ) + prev_button.callback = self.previous_page + self.add_item(prev_button) + + next_button = discord.ui.Button( + label="Next", + style=discord.ButtonStyle.secondary, + emoji=discord.PartialEmoji(name="right", id=1420240334100627456), + disabled=self.current_page == len(self.pages) - 1 + ) + next_button.callback = self.next_page + self.add_item(next_button) + + async def previous_page(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message("You can't control someone else's help menu!", ephemeral=True) + return + + if self.current_page > 0: + self.current_page -= 1 + self.update_buttons() + await interaction.response.edit_message(embed=self.create_embed(), view=self) + else: + await interaction.response.defer() + + async def next_page(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message("You can't control someone else's help menu!", ephemeral=True) + return + + if self.current_page < len(self.pages) - 1: + self.current_page += 1 + self.update_buttons() + await interaction.response.edit_message(embed=self.create_embed(), view=self) + else: + await interaction.response.defer() + + async def on_timeout(self): + for item in self.children: + item.disabled = True + + +class TweetyView(discord.ui.View): + def __init__(self, author_id: int, original_message, tweet_data: dict, api_url: str, image_message: Optional[discord.Message] = None): + super().__init__(timeout=300) + self.author_id = author_id + self.original_message = original_message + self.tweet_data = tweet_data + self.api_url = api_url + self.is_dark = tweet_data.get("dark", False) + self.is_verified = tweet_data.get("verified", False) + self.image_message = image_message + + self.update_button_styles() + + def update_button_styles(self): + self.clear_items() + + dark_button = discord.ui.Button( + label="Dark Mode" if self.is_dark else "Light Mode", + style=discord.ButtonStyle.primary if self.is_dark else discord.ButtonStyle.secondary, + emoji=discord.PartialEmoji(name="darkmode", id=1425165393751965884), + custom_id="toggle_dark" + ) + dark_button.callback = self.toggle_dark_callback + self.add_item(dark_button) + + verified_button = discord.ui.Button( + label="Verified", + style=discord.ButtonStyle.primary if self.is_verified else discord.ButtonStyle.secondary, + emoji=discord.PartialEmoji(name="TwitterVerifiedBadge", id=1425165432142172392), + custom_id="toggle_verified" + ) + verified_button.callback = self.toggle_verified_callback + self.add_item(verified_button) + + async def regenerate_tweet(self, interaction: discord.Interaction): + """Regenerate only the image message with current settings""" + await interaction.response.defer() + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.api_url}/api/render", + json=self.tweet_data, + headers={"Content-Type": "application/json"} + ) as response: + + if response.status != 200: + error_text = await response.text() + embed = discord.Embed( + title="Error", + description=f"API Error ({response.status}): {error_text}", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await interaction.followup.send(embed=embed, ephemeral=True) + return + + image_data = await response.read() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as temp_file: + temp_file.write(image_data) + temp_file_path = temp_file.name + + with open(temp_file_path, 'rb') as f: + author_name = self.original_message.author.name + filename = f"tweet_{author_name}_{int(datetime.now().timestamp())}.png" + file = discord.File( + f, + filename=filename + ) + + self.update_button_styles() + + if self.image_message is not None: + await self.image_message.edit(attachments=[file], view=self) + else: + await interaction.followup.send(file=file, view=self) + + os.remove(temp_file_path) + + except Exception as e: + embed = discord.Embed( + title="Error", + description="Error regenerating tweet image", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await interaction.followup.send(embed=embed, ephemeral=True) + + async def _check_author(self, interaction: discord.Interaction) -> bool: + """Check if user is authorized to modify the tweet""" + if interaction.user.id != self.author_id: + embed = discord.Embed( + title="Error", + description="You can't modify someone else's tweet!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await interaction.response.send_message(embed=embed, ephemeral=True) + return False + return True + + async def toggle_dark_callback(self, interaction: discord.Interaction): + """Handle dark mode toggle button click""" + if not await self._check_author(interaction): + return + self.is_dark = not self.is_dark + self.tweet_data["dark"] = self.is_dark + await self.regenerate_tweet(interaction) + + async def toggle_verified_callback(self, interaction: discord.Interaction): + """Handle verified toggle button click""" + if not await self._check_author(interaction): + return + self.is_verified = not self.is_verified + self.tweet_data["verified"] = self.is_verified + await self.regenerate_tweet(interaction) + + async def on_timeout(self): + """Disable buttons when view times out""" + for item in self.children: + item.disabled = True + +def tweety_command(): + @commands.hybrid_command( + name="tweety", + description="Convert a replied message to a tweet image." + ) + @commands.cooldown(1, 10, commands.BucketType.user) + async def tweety(self, context): + if hasattr(context, "interaction") and context.interaction: + view = TweetyHelpView(user_id=context.author.id, bot=self.bot) + embed = view.create_embed() + await context.send(embed=embed, view=view, ephemeral=True) + return + + if not context.message.reference or not context.message.reference.message_id: + embed = discord.Embed( + title="Error", + description="You must reply to a message to use this command!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + return + + try: + original_message = await context.channel.fetch_message(context.message.reference.message_id) + + processing_embed = discord.Embed( + title="Tweet Generator (Processing)", + description=" Generating tweet... This may take a moment.", + color=0x7289DA, + ) + processing_embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + processing_msg = await context.send(embed=processing_embed) + + author = original_message.author + display_name = author.display_name or author.name + username = f"@{author.name}" + avatar_url = str(author.avatar.url) if author.avatar else str(author.default_avatar.url) + message_text = original_message.content + + for mention in original_message.mentions: + message_text = message_text.replace(f'<@{mention.id}>', f'@{mention.name}') + message_text = message_text.replace(f'<@!{mention.id}>', f'@{mention.name}') + + for role in original_message.role_mentions: + message_text = message_text.replace(f'<@&{role.id}>', f'@{role.name}') + + for channel in original_message.channel_mentions: + message_text = message_text.replace(f'<#{channel.id}>', f'#{channel.name}') + + if not message_text.strip(): + await processing_msg.delete() + embed = discord.Embed( + title="Error", + description="No text found! This command only works with text messages.", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + return + + if len(message_text) > 300: + await processing_msg.delete() + embed = discord.Embed( + title="Error", + description=f"Message is too long! Maximum 300 characters allowed.\nYour message: {len(message_text)} characters", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + return + + message_text = break_long_words(message_text, max_word_length=50) + + ny_tz = pytz.timezone('America/New_York') + msg_time_ny = original_message.created_at.astimezone(ny_tz) + timestamp = msg_time_ny.strftime("%I:%M %p ยท %b %d, %Y").replace(" 0", " ") + tweet_data = { + "name": display_name[:50], + "handle": username[:20], + "text": message_text[:300], + "avatar": avatar_url, + "timestamp": timestamp, + "verified": False, + "dark": False + } + + API_BASE_URL = "https://tweety-api.vercel.app" + + async with aiohttp.ClientSession() as session: + try: + async with session.post( + f"{API_BASE_URL}/api/render", + json=tweet_data, + headers={"Content-Type": "application/json"} + ) as response: + + if response.status != 200: + await processing_msg.delete() + error_text = await response.text() + embed = discord.Embed( + title="Error", + description=f"API Error ({response.status}): {error_text}", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + return + + image_data = await response.read() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as temp_file: + temp_file.write(image_data) + temp_file_path = temp_file.name + + await processing_msg.delete() + + with open(temp_file_path, 'rb') as f: + file = discord.File(f, filename=f"tweet_{author.name}_{int(datetime.now().timestamp())}.png") + embed = discord.Embed( + title="Tweet Generated", + description=f"<:error:1424007141768822824> Tweet sometimes may look a bit broken, im gonna rewrite the API another time... (it wasnt made for Syntrel in the first place)", + color=0x7289DA, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + embed.set_footer( + text=f"Requested by {context.author.name}", + icon_url=context.author.display_avatar.url, + ) + + view = TweetyView( + author_id=context.author.id, + original_message=original_message, + tweet_data=tweet_data, + api_url=API_BASE_URL + ) + + await context.send(embed=embed) + image_message = await context.send(file=file, view=view) + view.image_message = image_message + + os.remove(temp_file_path) + + except aiohttp.ClientError: + await processing_msg.delete() + embed = discord.Embed( + title="Error", + description=f"Connection error: Could not reach tweet API", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + except Exception: + await processing_msg.delete() + embed = discord.Embed( + title="Error", + description="Error generating tweet image", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + + except Exception: + embed = discord.Embed( + title="Error", + description="Error processing the message!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + await context.send(embed=embed) + + return tweety diff --git a/requirements.txt b/requirements.txt index a9340be..3e23e11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ discord.py==2.6.3 python-dotenv yt-dlp Pillow -pillow-heif \ No newline at end of file +pillow-heif +pytz \ No newline at end of file