EveCogs/nexuscore/embedbuilder.py

512 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _scheduled_embed_loop(self):
"""Background task: load and run scheduled embeds on startup."""
await self.bot.wait_until_ready()
await self._load_scheduled_embeds()
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