"""NexusCore — Embed Builder: webhook-based embed creation, templates, scheduling, JSON import/export.""" from __future__ import annotations import asyncio import datetime import json from typing import Optional import discord from redbot.core import Config, commands from .utils import ( Clr, ok_embed, err_embed, info_embed, short_id, ts_now, ts_relative, parse_duration, duration_str, safe_send, Paginator, chunk_list, ) # ── Defaults ─────────────────────────────────────────────────────────────── EMBED_DEFAULTS_GUILD = { "webhooks": {}, # channel_id -> {webhook_id, webhook_url, name, avatar} "templates": {}, # name -> {embeds: [...], content, webhook_name, saved_by, saved_at} "scheduled": {}, # id -> {channel_id, embeds, content, send_at, interval, webhook_name, active} "history": [], # last 100 sent embeds [{channel_id, embeds, sent_at, sent_by}] } # ── Variables ────────────────────────────────────────────────────────────── VARIABLES = { "{server}": "Server name", "{server_name}": "Server name", "{server_icon}": "Server icon URL", "{member_count}": "Total members", "{channel_count}": "Total channels", "{role_count}": "Total roles", "{boost_count}": "Boost count", "{boost_level}": "Boost tier", "{date}": "Current date (YYYY-MM-DD)", "{time}": "Current time (HH:MM UTC)", "{datetime}": "Full datetime", "{nl}": "New line", "{user}": "Command user's mention", "{user_name}": "Command user's name", "{user_avatar}": "Command user's avatar URL", "{user_id}": "Command user's ID", } def replace_variables(text: str, guild: discord.Guild = None, user: discord.Member = None) -> str: """Replace all placeholders in text.""" if not text: return text now = datetime.datetime.now(datetime.timezone.utc) replacements = { "{server}": guild.name if guild else "Server", "{server_name}": guild.name if guild else "Server", "{server_icon}": str(guild.icon.url) if guild and guild.icon else "", "{member_count}": str(guild.member_count) if guild else "0", "{channel_count}": str(len(guild.channels)) if guild else "0", "{role_count}": str(len(guild.roles)) if guild else "0", "{boost_count}": str(guild.premium_subscription_count or 0) if guild else "0", "{boost_level}": str(guild.premium_tier) if guild else "0", "{date}": now.strftime("%Y-%m-%d"), "{time}": now.strftime("%H:%M UTC"), "{datetime}": now.strftime("%Y-%m-%d %H:%M UTC"), "{nl}": "\n", "{user}": user.mention if user else "", "{user_name}": str(user) if user else "", "{user_avatar}": str(user.display_avatar.url) if user and user.display_avatar else "", "{user_id}": str(user.id) if user else "", } for k, v in replacements.items(): text = text.replace(k, v) return text def embed_from_dict(data: dict, guild=None, user=None) -> discord.Embed: """Build a discord.Embed from a dict with variable replacement.""" embed = discord.Embed() if data.get("title"): embed.title = replace_variables(data["title"], guild, user)[:256] if data.get("description"): embed.description = replace_variables(data["description"], guild, user)[:4096] if data.get("url"): embed.url = data["url"] if data.get("color") or data.get("colour"): c = data.get("color") or data.get("colour") if isinstance(c, str): c = int(c.lstrip("#"), 16) embed.colour = discord.Colour(c) if data.get("timestamp"): if data["timestamp"] is True: embed.timestamp = datetime.datetime.now(datetime.timezone.utc) else: try: embed.timestamp = datetime.datetime.fromisoformat(data["timestamp"]) except (ValueError, TypeError): embed.timestamp = datetime.datetime.now(datetime.timezone.utc) if data.get("author"): a = data["author"] embed.set_author( name=replace_variables(a.get("name", ""), guild, user)[:256], url=a.get("url") or None, icon_url=a.get("icon_url") or None, ) if data.get("footer"): f = data["footer"] embed.set_footer( text=replace_variables(f.get("text", ""), guild, user)[:2048], icon_url=f.get("icon_url") or None, ) if data.get("image"): embed.set_image(url=data["image"]) if data.get("thumbnail"): embed.set_thumbnail(url=data["thumbnail"]) for field in data.get("fields", [])[:25]: embed.add_field( name=replace_variables(field.get("name", "\u200b"), guild, user)[:256], value=replace_variables(field.get("value", "\u200b"), guild, user)[:1024], inline=field.get("inline", True), ) return embed def embed_to_dict(embed: discord.Embed) -> dict: """Convert a discord.Embed to a JSON-serializable dict (Discohook compatible).""" data = {} if embed.title: data["title"] = embed.title if embed.description: data["description"] = embed.description if embed.url: data["url"] = embed.url if embed.colour: data["color"] = embed.colour.value if embed.timestamp: data["timestamp"] = embed.timestamp.isoformat() if embed.author: data["author"] = {} if embed.author.name: data["author"]["name"] = embed.author.name if embed.author.url: data["author"]["url"] = embed.author.url if embed.author.icon_url: data["author"]["icon_url"] = embed.author.icon_url if embed.footer: data["footer"] = {} if embed.footer.text: data["footer"]["text"] = embed.footer.text if embed.footer.icon_url: data["footer"]["icon_url"] = embed.footer.icon_url if embed.image: data["image"] = embed.image.url if embed.thumbnail: data["thumbnail"] = embed.thumbnail.url if embed.fields: data["fields"] = [ {"name": f.name, "value": f.value, "inline": f.inline} for f in embed.fields ] return data # ── Modals ───────────────────────────────────────────────────────────────── class EmbedTitleModal(discord.ui.Modal, title="Embed — Title & Description"): embed_title = discord.ui.TextInput( label="Title", max_length=256, required=False, placeholder="My awesome embed", ) description = discord.ui.TextInput( label="Description", style=discord.TextStyle.paragraph, max_length=4000, required=False, placeholder="Supports **markdown** and {variables}", ) url = discord.ui.TextInput( label="Title URL", max_length=500, required=False, placeholder="https://example.com", ) colour = discord.ui.TextInput( label="Colour (hex)", max_length=7, required=False, placeholder="#5865F2", ) def __init__(self, callback): super().__init__() self._callback = callback async def on_submit(self, interaction: discord.Interaction): await self._callback(interaction, { "title": self.embed_title.value, "description": self.description.value, "url": self.url.value, "colour": self.colour.value, }) class EmbedAuthorFooterModal(discord.ui.Modal, title="Embed — Author & Footer"): author_name = discord.ui.TextInput(label="Author Name", max_length=256, required=False) author_icon = discord.ui.TextInput(label="Author Icon URL", max_length=500, required=False) author_url = discord.ui.TextInput(label="Author URL", max_length=500, required=False) footer_text = discord.ui.TextInput(label="Footer Text", max_length=2048, required=False) footer_icon = discord.ui.TextInput(label="Footer Icon URL", max_length=500, required=False) def __init__(self, callback): super().__init__() self._callback = callback async def on_submit(self, interaction: discord.Interaction): await self._callback(interaction, { "author": {"name": self.author_name.value, "icon_url": self.author_icon.value, "url": self.author_url.value}, "footer": {"text": self.footer_text.value, "icon_url": self.footer_icon.value}, }) class EmbedImagesModal(discord.ui.Modal, title="Embed — Images"): image = discord.ui.TextInput(label="Image URL", max_length=500, required=False, placeholder="https://i.imgur.com/...") thumbnail = discord.ui.TextInput(label="Thumbnail URL", max_length=500, required=False) def __init__(self, callback): super().__init__() self._callback = callback async def on_submit(self, interaction: discord.Interaction): await self._callback(interaction, { "image": self.image.value, "thumbnail": self.thumbnail.value, }) class EmbedFieldModal(discord.ui.Modal, title="Add Field"): field_name = discord.ui.TextInput(label="Field Name", max_length=256) field_value = discord.ui.TextInput(label="Field Value", style=discord.TextStyle.paragraph, max_length=1024) inline = discord.ui.TextInput(label="Inline? (yes/no)", max_length=3, required=False, default="yes") def __init__(self, callback): super().__init__() self._callback = callback async def on_submit(self, interaction: discord.Interaction): await self._callback(interaction, { "name": self.field_name.value, "value": self.field_value.value, "inline": self.inline.value.lower() in ("yes", "y", "true", "1"), }) class EmbedWebhookModal(discord.ui.Modal, title="Webhook Settings"): wh_name = discord.ui.TextInput(label="Webhook Name", max_length=80, default="NexusCore") wh_avatar = discord.ui.TextInput(label="Avatar URL", max_length=500, required=False) content = discord.ui.TextInput(label="Message Content (outside embed)", style=discord.TextStyle.paragraph, max_length=2000, required=False) def __init__(self, callback): super().__init__() self._callback = callback async def on_submit(self, interaction: discord.Interaction): await self._callback(interaction, { "webhook_name": self.wh_name.value, "webhook_avatar": self.wh_avatar.value, "content": self.content.value, }) # ── Builder View ─────────────────────────────────────────────────────────── class EmbedBuilderView(discord.ui.View): """Interactive embed builder with buttons for each section.""" def __init__(self, cog, author: discord.Member, channel: discord.TextChannel = None): super().__init__(timeout=600) self.cog = cog self.author = author self.target_channel = channel self.embed_data = {} self.fields = [] self.webhook_name = "NexusCore" self.webhook_avatar = "" self.content = "" def _build_preview(self) -> discord.Embed: data = dict(self.embed_data) data["fields"] = self.fields if data or self.fields: embed = embed_from_dict(data, self.author.guild, self.author) if not embed.title and not embed.description and not embed.fields: embed.description = "*Empty embed — use the buttons below to add content*" return embed return discord.Embed(description="*Empty embed — use the buttons below to add content*", colour=Clr.INFO) async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user.id != self.author.id: await interaction.response.send_message("Not your builder.", ephemeral=True) return False return True @discord.ui.button(label="Title & Desc", style=discord.ButtonStyle.primary, emoji="📝", row=0) async def title_btn(self, interaction: discord.Interaction, button: discord.ui.Button): async def cb(inter, data): if data.get("title"): self.embed_data["title"] = data["title"] if data.get("description"): self.embed_data["description"] = data["description"] if data.get("url"): self.embed_data["url"] = data["url"] if data.get("colour"): try: self.embed_data["colour"] = data["colour"] except ValueError: pass await inter.response.edit_message(embed=self._build_preview(), view=self) await interaction.response.send_modal(EmbedTitleModal(cb)) @discord.ui.button(label="Author & Footer", style=discord.ButtonStyle.primary, emoji="👤", row=0) async def author_btn(self, interaction: discord.Interaction, button: discord.ui.Button): async def cb(inter, data): if data.get("author") and any(data["author"].values()): self.embed_data["author"] = data["author"] if data.get("footer") and any(data["footer"].values()): self.embed_data["footer"] = data["footer"] await inter.response.edit_message(embed=self._build_preview(), view=self) await interaction.response.send_modal(EmbedAuthorFooterModal(cb)) @discord.ui.button(label="Images", style=discord.ButtonStyle.primary, emoji="🖼️", row=0) async def images_btn(self, interaction: discord.Interaction, button: discord.ui.Button): async def cb(inter, data): if data.get("image"): self.embed_data["image"] = data["image"] if data.get("thumbnail"): self.embed_data["thumbnail"] = data["thumbnail"] await inter.response.edit_message(embed=self._build_preview(), view=self) await interaction.response.send_modal(EmbedImagesModal(cb)) @discord.ui.button(label="Add Field", style=discord.ButtonStyle.secondary, emoji="➕", row=0) async def field_btn(self, interaction: discord.Interaction, button: discord.ui.Button): if len(self.fields) >= 25: return await interaction.response.send_message("Max 25 fields.", ephemeral=True) async def cb(inter, data): self.fields.append(data) await inter.response.edit_message(embed=self._build_preview(), view=self) await interaction.response.send_modal(EmbedFieldModal(cb)) @discord.ui.button(label="Timestamp", style=discord.ButtonStyle.secondary, emoji="🕐", row=1) async def timestamp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.embed_data["timestamp"] = True await interaction.response.edit_message(embed=self._build_preview(), view=self) @discord.ui.button(label="Webhook Settings", style=discord.ButtonStyle.secondary, emoji="🔗", row=1) async def webhook_btn(self, interaction: discord.Interaction, button: discord.ui.Button): async def cb(inter, data): if data.get("webhook_name"): self.webhook_name = data["webhook_name"] if data.get("webhook_avatar"): self.webhook_avatar = data["webhook_avatar"] if data.get("content"): self.content = data["content"] await inter.response.send_message( f"✅ Webhook: **{self.webhook_name}**" + (f"\nContent: {self.content[:100]}" if self.content else ""), ephemeral=True, ) await interaction.response.send_modal(EmbedWebhookModal(cb)) @discord.ui.button(label="Clear Fields", style=discord.ButtonStyle.danger, emoji="🗑️", row=1) async def clear_fields_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.fields.clear() await interaction.response.edit_message(embed=self._build_preview(), view=self) @discord.ui.button(label="Reset All", style=discord.ButtonStyle.danger, emoji="♻️", row=1) async def reset_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.embed_data.clear() self.fields.clear() self.content = "" await interaction.response.edit_message(embed=self._build_preview(), view=self) @discord.ui.button(label="📤 Send", style=discord.ButtonStyle.success, emoji=None, row=2) async def send_btn(self, interaction: discord.Interaction, button: discord.ui.Button): if not self.target_channel: return await interaction.response.send_message("No target channel set. Use `[p]embed send #channel`", ephemeral=True) data = dict(self.embed_data) data["fields"] = self.fields embed = embed_from_dict(data, interaction.guild, interaction.user) webhook = None for wh in await self.target_channel.webhooks(): if wh.name == self.webhook_name: webhook = wh break if not webhook: webhook = await self.target_channel.create_webhook(name=self.webhook_name) await webhook.send( content=self.content or None, embed=embed, username=self.webhook_name, avatar_url=self.webhook_avatar or None, ) await interaction.response.send_message(f"✅ Embed sent to {self.target_channel.mention}!", ephemeral=True) # Log to history async with self.cog.embed_config.guild(interaction.guild).history() as hist: hist.append({ "channel_id": self.target_channel.id, "embed": data, "content": self.content, "sent_at": ts_now(), "sent_by": interaction.user.id, }) if len(hist) > 100: hist.pop(0) @discord.ui.button(label="💾 Export JSON", style=discord.ButtonStyle.secondary, emoji=None, row=2) async def export_btn(self, interaction: discord.Interaction, button: discord.ui.Button): import io data = dict(self.embed_data) data["fields"] = self.fields # Discohook-compatible format export = { "content": self.content, "embeds": [data], } raw = json.dumps(export, indent=2) file = discord.File(io.BytesIO(raw.encode()), filename="embed.json") await interaction.response.send_message("📋 JSON export (Discohook compatible):", file=file, ephemeral=True) @discord.ui.button(label="💾 Save Template", style=discord.ButtonStyle.secondary, emoji=None, row=2) async def save_template_btn(self, interaction: discord.Interaction, button: discord.ui.Button): class NameModal(discord.ui.Modal, title="Save Template"): tname = discord.ui.TextInput(label="Template Name", max_length=50) async def on_submit(modal_self, inter: discord.Interaction): data = dict(self.embed_data) data["fields"] = self.fields async with self.cog.embed_config.guild(inter.guild).templates() as templates: templates[modal_self.tname.value] = { "embeds": [data], "content": self.content, "webhook_name": self.webhook_name, "saved_by": inter.user.id, "saved_at": ts_now(), } await inter.response.send_message(f"✅ Template **{modal_self.tname.value}** saved!", ephemeral=True) await interaction.response.send_modal(NameModal()) # ── Mixin ────────────────────────────────────────────────────────────────── class EmbedBuilderMixin: """Embed builder system mixin.""" def _init_embed_builder(self, bot): self.embed_config = Config.get_conf( None, identifier=900009, cog_name="NexusCoreEmbed" ) self.embed_config.register_guild(**EMBED_DEFAULTS_GUILD) self._scheduled_embed_tasks = {} async def _load_scheduled_embeds(self): """Load and schedule pending embed sends.""" for guild in self.bot.guilds: scheduled = await self.embed_config.guild(guild).scheduled() for sid, sched in scheduled.items(): if sched.get("active"): self._schedule_embed_send(guild, sid, sched) def _schedule_embed_send(self, guild, sched_id, sched_data): async def _task(): try: send_at = sched_data.get("send_at", 0) now = ts_now() if send_at > now: await asyncio.sleep(send_at - now) channel = guild.get_channel(sched_data.get("channel_id")) if not channel: return embeds = [embed_from_dict(e, guild) for e in sched_data.get("embeds", [])[:10]] content = sched_data.get("content") wh_name = sched_data.get("webhook_name", "NexusCore") webhook = None for wh in await channel.webhooks(): if wh.name == wh_name: webhook = wh break if not webhook: webhook = await channel.create_webhook(name=wh_name) await webhook.send(content=content or None, embeds=embeds or None, username=wh_name) interval = sched_data.get("interval", 0) if interval > 0: # Recurring — reschedule async with self.embed_config.guild(guild).scheduled() as scheds: if sched_id in scheds: scheds[sched_id]["send_at"] = ts_now() + interval self._schedule_embed_send(guild, sched_id, {**sched_data, "send_at": ts_now() + interval}) else: async with self.embed_config.guild(guild).scheduled() as scheds: if sched_id in scheds: scheds[sched_id]["active"] = False except Exception: pass task = asyncio.create_task(_task()) self._scheduled_embed_tasks[sched_id] = task