EveCogs/newhelpmenu/converter.py
everestmcarthur 324524fc77 refactor: NewHelpMenu → Components V2 mega cog (complete rewrite)
Replaces embed-based help with full Components V2 system:
- converter.py: Embed → Container conversion engine
- formatter.py: CV2 help page builder + paginator views
- newhelpmenu.py: Main cog with settings, monkey-patching, public API
- 1,747 lines across 4 files, default off
2026-05-07 22:41:31 +00:00

256 lines
8.3 KiB
Python

"""
Embed → Components V2 converter.
Translates discord.Embed objects into discord.ui LayoutView trees
using Containers, Sections, TextDisplays, Thumbnails, MediaGalleries,
Separators, and ActionRows.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Tuple, Union
import discord
from discord import ui
# ──────────────────────────── helpers ────────────────────────────
def _truncate(text: str, limit: int = 4000) -> str:
"""Truncate to Discord's TextDisplay limit."""
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
def _field_to_md(field: Any) -> str:
"""Convert an embed field to markdown."""
name = getattr(field, "name", "") or ""
value = getattr(field, "value", "") or ""
if name:
return f"**{name}**\n{value}"
return value
# ──────────────────────────── core ────────────────────────────
def embed_to_container(
embed: discord.Embed,
*,
accent_color: Optional[int] = None,
show_thumbnail: bool = True,
show_footer: bool = True,
compact: bool = False,
) -> ui.Container:
"""Convert a ``discord.Embed`` into a ``discord.ui.Container``.
Parameters
----------
embed:
The embed to convert.
accent_color:
Override accent colour. Falls back to embed colour, then None.
show_thumbnail:
Whether to render the thumbnail as a Thumbnail accessory.
show_footer:
Whether to render the footer line.
compact:
If True, merges fields into fewer TextDisplays.
"""
# Resolve colour
color = accent_color
if color is None and embed.color and embed.color.value:
color = embed.color.value
if color is None:
color = discord.Color.blurple().value
container = ui.Container(accent_colour=discord.Colour(color))
# ── Author line ──
author_text = ""
if embed.author and embed.author.name:
author_name = embed.author.name
if embed.author.url:
author_text = f"[{author_name}]({embed.author.url})"
else:
author_text = author_name
# ── Title + URL ──
title_text = ""
if embed.title:
title = embed.title
if embed.url:
title_text = f"### [{title}]({embed.url})"
else:
title_text = f"### {title}"
# ── Description ──
desc_text = embed.description or ""
# Build the header block — combine author, title, description
header_parts = []
if author_text:
header_parts.append(author_text)
if title_text:
header_parts.append(title_text)
if desc_text:
header_parts.append(desc_text)
header_md = "\n".join(header_parts)
# ── Thumbnail? → use Section with accessory ──
thumbnail_url = None
if show_thumbnail and embed.thumbnail and embed.thumbnail.url:
thumbnail_url = embed.thumbnail.url
if header_md:
if thumbnail_url:
# Section: header text + thumbnail accessory
section = ui.Section(
accessory=ui.Thumbnail(thumbnail_url),
)
section.add_item(ui.TextDisplay(_truncate(header_md)))
container.add_item(section)
else:
container.add_item(ui.TextDisplay(_truncate(header_md)))
# ── Fields ──
if embed.fields:
if header_md:
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
if compact:
# Merge all fields into one or two text displays
inline_row: List[str] = []
blocks: List[str] = []
for field in embed.fields:
md = _field_to_md(field)
if getattr(field, "inline", False):
inline_row.append(md)
if len(inline_row) >= 3:
blocks.append(" · ".join(inline_row))
inline_row = []
else:
if inline_row:
blocks.append(" · ".join(inline_row))
inline_row = []
blocks.append(md)
if inline_row:
blocks.append(" · ".join(inline_row))
combined = "\n\n".join(blocks)
container.add_item(ui.TextDisplay(_truncate(combined)))
else:
# Group inline fields, render non-inline as standalone
inline_buffer: List[str] = []
for field in embed.fields:
md = _field_to_md(field)
if getattr(field, "inline", False):
inline_buffer.append(md)
if len(inline_buffer) >= 3:
joined = " · ".join(inline_buffer)
container.add_item(ui.TextDisplay(_truncate(joined)))
inline_buffer = []
else:
if inline_buffer:
joined = " · ".join(inline_buffer)
container.add_item(ui.TextDisplay(_truncate(joined)))
inline_buffer = []
container.add_item(ui.TextDisplay(_truncate(md)))
if inline_buffer:
joined = " · ".join(inline_buffer)
container.add_item(ui.TextDisplay(_truncate(joined)))
# ── Image ──
if embed.image and embed.image.url:
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
container.add_item(
ui.MediaGallery(
ui.MediaGalleryItem(embed.image.url)
)
)
# ── Footer + timestamp ──
if show_footer:
footer_parts: List[str] = []
if embed.footer and embed.footer.text:
footer_parts.append(embed.footer.text)
if embed.timestamp:
footer_parts.append(
f"<t:{int(embed.timestamp.timestamp())}:R>"
)
if footer_parts:
container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
footer_md = "-# " + " · ".join(footer_parts)
container.add_item(ui.TextDisplay(_truncate(footer_md, 4000)))
return container
def embeds_to_layout(
embeds: List[discord.Embed],
content: Optional[str] = None,
*,
accent_color: Optional[int] = None,
show_thumbnail: bool = True,
show_footer: bool = True,
compact: bool = False,
existing_action_rows: Optional[List[ui.ActionRow]] = None,
) -> ui.LayoutView:
"""Convert a list of embeds (and optional content) to a full LayoutView.
Parameters
----------
embeds:
List of embeds to convert.
content:
Optional text content to add before embeds.
accent_color:
Global accent colour override.
show_thumbnail:
Whether to render thumbnails.
show_footer:
Whether to render footers.
compact:
Compact field rendering.
existing_action_rows:
Pre-built action rows (buttons/selects) to append.
"""
layout = ui.LayoutView()
# Optional leading text
if content:
layout.add_item(ui.TextDisplay(_truncate(content)))
# Convert each embed to a container
for i, embed in enumerate(embeds):
if i > 0:
layout.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small))
container = embed_to_container(
embed,
accent_color=accent_color,
show_thumbnail=show_thumbnail,
show_footer=show_footer,
compact=compact,
)
layout.add_item(container)
# Append any action rows (buttons, selects)
if existing_action_rows:
for row in existing_action_rows:
layout.add_item(row)
return layout
def view_items_to_action_rows(view: ui.View) -> List[ui.ActionRow]:
"""Extract action rows from a regular View for re-use in LayoutView."""
rows: List[ui.ActionRow] = []
for child in view.children:
if isinstance(child, ui.ActionRow):
rows.append(child)
elif isinstance(child, (ui.Button, ui.Select)):
row = ui.ActionRow()
row.add_item(child)
rows.append(row)
return rows