From 25ea98236d28794176b40f3cc59b0bd93e5a93fb Mon Sep 17 00:00:00 2001 From: neoarz Date: Tue, 7 Oct 2025 13:33:53 -0400 Subject: [PATCH] feat(tweety): new command :) --- cogs/media/__init__.py | 47 ++++- cogs/media/tweety.py | 448 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 cogs/media/tweety.py diff --git a/cogs/media/__init__.py b/cogs/media/__init__.py index 11721aa..43f2475 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,34 @@ 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 'tweety' in content_without_mention: + parts = content_without_mention.split() + verified = "false" + theme = "light" + + for i, part in enumerate(parts): + if part == 'tweety': + if i + 1 < len(parts): + if parts[i + 1] in ['verified', 'true', 'yes']: + verified = "true" + if 'dark' in parts or 'night' in parts: + theme = "dark" + break + + ctx = await self.bot.get_context(message) + + await self.tweety(ctx, verified=verified, theme=theme) + @commands.group(name="media", invoke_without_command=True) async def media_group(self, context: Context): embed = discord.Embed( @@ -31,7 +60,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 +73,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, verified=kwargs.get('verified', "false"), theme=kwargs.get('theme', "light")) + return await context.send(f"Unknown media command: {name}") @media_group.command(name="download") @@ -58,6 +90,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, verified: str = "false", theme: str = "light"): + await self._invoke_hybrid(context, "tweety", verified=verified, theme=theme) + @commands.check(_require_group_prefix) @commands.hybrid_command( name="download", @@ -82,6 +118,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, verified: str = "false", theme: str = "light"): + return await tweety_command()(self, context, verified=verified, theme=theme) + async def setup(bot) -> None: cog = Media(bot) await bot.add_cog(cog) @@ -89,3 +133,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..10317af --- /dev/null +++ b/cogs/media/tweety.py @@ -0,0 +1,448 @@ +import asyncio +import os +import tempfile +import discord +from discord.ext import commands +from discord import app_commands +import aiohttp +import io +from datetime import datetime +from typing import Optional + + +class TweetyView(discord.ui.View): + """ API for Tweety is hosted on Vercel and made by me :) github can be found here: https://github.com/neoarz/tweety-api""" + + 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): + """Update button styles to reflect current state""" + 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 toggle_dark_callback(self, interaction: discord.Interaction): + """Handle dark mode toggle button click""" + 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 + + 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 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 + + 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 + + try: + pass + except: + pass + +def tweety_command(): + @commands.hybrid_command( + name="tweety", + description="Convert a replied message to a tweet image." + ) + @app_commands.describe( + verified="Add a verified badge to the tweet", + theme="Choose the theme for the tweet" + ) + @app_commands.choices(verified=[ + app_commands.Choice(name="No", value="false"), + app_commands.Choice(name="Yes", value="true") + ]) + @app_commands.choices(theme=[ + app_commands.Choice(name="Light", value="light"), + app_commands.Choice(name="Dark", value="dark") + ]) + @commands.cooldown(1, 10, commands.BucketType.user) + async def tweety(self, context, verified: Optional[str] = "false", theme: Optional[str] = "light"): + interaction = getattr(context, "interaction", None) + if interaction is not None and not isinstance(context.message, discord.Message): + try: + embed = discord.Embed( + title="Tweety", + description=( + "Use the prefix command: `.media tweety`\n" + f"Or reply to a message with: <@{self.bot.user.id}> tweety" + ), + color=0x7289DA, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception: + pass + return + verified_bool = verified == "true" + theme_bool = theme == "dark" + + 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") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await context.send(embed=embed, ephemeral=True) + return + + original_message = await context.channel.fetch_message(context.message.reference.message_id) + + try: + if not original_message: + embed = discord.Embed( + title="Error", + description="Could not find the original message!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await context.send(embed=embed, ephemeral=True) + return + + if original_message.author.bot: + embed = discord.Embed( + title="Error", + description="Cannot convert bot messages to tweets!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await context.send(embed=embed, ephemeral=True) + return + + 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") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=processing_embed, ephemeral=True) + else: + await interaction.followup.send(embed=processing_embed, ephemeral=True) + else: + 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 + + image_url = None + if original_message.attachments: + for attachment in original_message.attachments: + if any(attachment.filename.lower().endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp']): + raw_url = attachment.url + if 'cdn.discordapp.com' in raw_url: + media_url = raw_url.replace('cdn.discordapp.com', 'media.discordapp.net') + if '?' not in media_url: + media_url += f"?width={attachment.width}&height={attachment.height}" + elif 'width=' not in media_url and attachment.width: + media_url += f"&width={attachment.width}&height={attachment.height}" + image_url = media_url + else: + image_url = raw_url + break + + if not message_text.strip() and not image_url: + embed = discord.Embed( + title="Error", + description="Message must have either text content or an image/GIF to convert!", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + try: + await interaction.delete_original_response() + except: + pass + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await processing_msg.delete() + await context.send(embed=embed, ephemeral=True) + return + + msg_time = original_message.created_at + timestamp = msg_time.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": verified_bool, + "dark": theme_bool + } + + if image_url: + tweet_data["image"] = image_url + + 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: + 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") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + try: + await interaction.delete_original_response() + except: + pass + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await processing_msg.delete() + await context.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: + file = discord.File(f, filename=f"tweet_{author.name}_{int(datetime.now().timestamp())}.png") + embed = discord.Embed( + title="Tweet Generated", + 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 + ) + + interaction = getattr(context, "interaction", None) + if interaction is not None: + embed_message = await context.channel.send(embed=embed) + image_message = await context.channel.send(file=file, view=view) + view.image_message = image_message + try: + await interaction.delete_original_response() + except: + pass + else: + await processing_msg.delete() + embed_message = await context.channel.send(embed=embed) + image_message = await context.channel.send(file=file, view=view) + view.image_message = image_message + + os.remove(temp_file_path) + + except aiohttp.ClientError as e: + embed = discord.Embed( + title="Error", + description=f"Connection error: Could not reach tweet API at {API_BASE_URL}", + color=0xE02B2B, + ) + embed.set_author(name="Media", icon_url="https://yes.nighty.works/raw/y5SEZ9.webp") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + try: + await interaction.delete_original_response() + except: + pass + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await processing_msg.delete() + await context.send(embed=embed, ephemeral=True) + except Exception as e: + 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") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + try: + await interaction.delete_original_response() + except: + pass + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await processing_msg.delete() + await context.send(embed=embed, ephemeral=True) + + except Exception as e: + 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") + + interaction = getattr(context, "interaction", None) + if interaction is not None: + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + else: + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await context.send(embed=embed, ephemeral=True) + + return tweety