From e8fe0b0dd1f27fee99179d75eea2e062450d02c5 Mon Sep 17 00:00:00 2001 From: Pat <86346146+foolishbuilder@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:54:20 +1000 Subject: [PATCH] Restyle Bill component surfaces Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bill/cogs/profile.py | 11 +- bill/cogs/setup.py | 10 +- bill/components/profile.py | 179 ++++++++++++--- bill/components/public_profile.py | 165 ++++++++++---- bill/components/setup.py | 89 +++++++- docs/profiles.md | 19 +- tests/test_profiles.py | 360 +++++++++++++++++++++++++++--- 7 files changed, 713 insertions(+), 120 deletions(-) diff --git a/bill/cogs/profile.py b/bill/cogs/profile.py index a1456a1..039922a 100644 --- a/bill/cogs/profile.py +++ b/bill/cogs/profile.py @@ -8,7 +8,7 @@ from discord import app_commands from discord.ext import commands -from bill.components.profile import profile_wizard_view +from bill.components.profile import member_presentation, profile_intro_view from bill.components.public_profile import public_profile_view from bill.worker_client import DraftScope, ProfileDraft, ServerProfileMode, WorkerAPIError @@ -145,7 +145,7 @@ async def profile( lookup.profile, guild_id=interaction.guild_id or 0, owner_view=target.id == interaction.user.id, - display_name=target.display_name, + presentation=member_presentation(target), ) ) return @@ -224,9 +224,12 @@ async def deliver_draft( try: dm = await interaction.user.create_dm() await dm.send( - "Welcome to Bill profile setup. Your progress is private and saved automatically." + "Welcome to Bill profile setup. This private wizard helps you choose what " + "other members can see, add public links, and optionally connect Throne. " + "Your progress saves automatically, and nothing is published until you " + "confirm it.", + view=profile_intro_view(draft), ) - await dm.send(view=profile_wizard_view(draft)) except discord.Forbidden: if interaction.response.is_done(): await interaction.followup.send( diff --git a/bill/cogs/setup.py b/bill/cogs/setup.py index f76b207..50a6ae0 100644 --- a/bill/cogs/setup.py +++ b/bill/cogs/setup.py @@ -8,7 +8,7 @@ from discord import app_commands from discord.ext import commands -from bill.components.setup import missing_channel_permissions, setup_view +from bill.components.setup import guild_presentation, missing_channel_permissions, setup_view from bill.worker_client import WorkerAPIError if TYPE_CHECKING: @@ -50,7 +50,13 @@ async def setup(self, interaction: discord.Interaction[discord.Client]) -> None: ) return # It is public for moderator visibility, while callback authorization is initiator-bound. - await interaction.response.send_message(view=setup_view(started.session), ephemeral=False) + await interaction.response.send_message( + view=setup_view( + started.session, + presentation=guild_presentation(interaction.guild, interaction.user), + ), + ephemeral=False, + ) __all__ = ["BillSetupCog", "missing_channel_permissions"] diff --git a/bill/components/profile.py b/bill/components/profile.py index 41d634e..9850b7e 100644 --- a/bill/components/profile.py +++ b/bill/components/profile.py @@ -9,6 +9,7 @@ import re from collections.abc import Iterable +from dataclasses import dataclass from typing import TYPE_CHECKING, cast import discord @@ -63,6 +64,7 @@ Orientation.SWITCH_SUBMISSIVE: "Switch (submissive lean)", } PROFILE_WIZARD_BUTTON_ACTIONS = ( + "start", "publish", "restart", "identity", @@ -105,6 +107,25 @@ def safe_text(value: str, *, limit: int = 300) -> str: return discord.utils.escape_mentions(discord.utils.escape_markdown(value))[:limit] +@dataclass(frozen=True, slots=True) +class MemberPresentation: + display_name: str + avatar_url: str | None = None + + +def member_presentation(user: object) -> MemberPresentation: + """Read transient Discord presentation data without adding it to Worker state.""" + display_name = ( + getattr(user, "display_name", None) + or getattr(user, "global_name", None) + or getattr(user, "name", None) + or "Bill member" + ) + avatar = getattr(user, "display_avatar", None) + avatar_url = getattr(avatar, "url", None) + return MemberPresentation(str(display_name), str(avatar_url) if avatar_url else None) + + def wizard_custom_id(draft: ProfileDraft, action: str) -> str: """Build a <=100-character persistent ID bound to all durable auth context.""" if action not in _PROFILE_WIZARD_ACTIONS: @@ -163,27 +184,101 @@ def _button( return discord.ui.Button(label=label, custom_id=wizard_custom_id(draft, action), style=style) -def profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: +def profile_intro_view(draft: ProfileDraft) -> discord.ui.View: + """Build the normal, durable DM introduction shown before Components V2.""" + view = discord.ui.View(timeout=None) + view.add_item( + discord.ui.Button( + label="Start", + custom_id=wizard_custom_id(draft, "start"), + style=discord.ButtonStyle.success, + ) + ) + return view + + +def _member_section( + presentation: MemberPresentation, + *, + current_label: str, + scope_label: str, +) -> discord.ui.Section | discord.ui.TextDisplay: + title = f"### {safe_text(presentation.display_name, limit=80)}" + metadata = ( + f"-# Current step: {safe_text(current_label, limit=80)}", + f"-# Profile: {safe_text(scope_label, limit=80)} · progress saves automatically", + ) + if presentation.avatar_url: + return discord.ui.Section( + discord.ui.TextDisplay(title), + *(discord.ui.TextDisplay(row) for row in metadata), + accessory=discord.ui.Thumbnail( + presentation.avatar_url, + description=f"{safe_text(presentation.display_name, limit=80)}'s avatar", + ), + ) + return discord.ui.TextDisplay("\n".join((title, *metadata))) + + +def _current_step_label(step: DraftStepKey) -> str: + return { + DraftStepKey.ORIENTATION: "Choose orientation", + DraftStepKey.IDENTITY: "Identity", + DraftStepKey.LINKS: "Links", + DraftStepKey.THRONE: "Throne", + DraftStepKey.REVIEW: "Review and publish", + }[step] + + +def profile_wizard_view( + draft: ProfileDraft, + *, + presentation: MemberPresentation | None = None, +) -> discord.ui.LayoutView: """Render the sole editable V2 wizard message from the latest Worker state.""" + presentation = presentation or MemberPresentation("Bill member") + current = draft.next_step or draft.current_step or DraftStepKey.REVIEW + scope_label = ( + "Global" + if draft.target_scope is DraftScope.GLOBAL + else ( + "Server (linked)" + if draft.server_mode is ServerProfileMode.LINKED + else "Server (independent)" + ) + ) view = discord.ui.LayoutView(timeout=None) container = discord.ui.Container(accent_color=discord.Color.green()) - container.add_item(discord.ui.TextDisplay("## Build your Bill profile")) - for step in draft.steps: - if step.status == "completed": - container.add_item( - discord.ui.TextDisplay( - f"-# **{step.key.value.title()}**: {_summary(draft, step.key)} (Complete)" - ) - ) - current = draft.next_step or draft.current_step + container.add_item(discord.ui.TextDisplay("-# Bill Profile Setup")) + container.add_item( + _member_section( + presentation, + current_label=_current_step_label(current), + scope_label=scope_label, + ) + ) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) + completed = [ + f"-# **{step.key.value.title()}**: {_summary(draft, step.key)} (Complete)" + for step in draft.steps + if step.status == "completed" + ] + if completed: + container.add_item(discord.ui.TextDisplay("\n".join(completed))) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) if current is DraftStepKey.ORIENTATION: - container.add_item(discord.ui.TextDisplay("### 1. Choose your orientation")) + container.add_item( + discord.ui.TextDisplay( + "Choose the orientation that best fits this profile. It controls which " + "identity, payment, and Throne options appear later." + ) + ) container.add_item(discord.ui.ActionRow(OrientationSelect(draft))) elif current is DraftStepKey.IDENTITY: container.add_item( discord.ui.TextDisplay( - "### Identity\nSet fixed selections, DM status, bio, aliases, and public stats " - "preference." + "Choose the labels you want to show, then add your DM status, optional bio, " + "aliases, and public send-stat preference." ) ) container.add_item( @@ -226,7 +321,8 @@ def profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: _, _, _, payment, _ = _caps(draft.governing_orientation) container.add_item( discord.ui.TextDisplay( - "### Links\nManage individual social/payment links or import a link page." + "Add social or payment links one at a time, or import a supported public " + "link page. Only enabled HTTPS links are shown publicly." ) ) links = [ @@ -249,8 +345,8 @@ def profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: elif current is DraftStepKey.THRONE: container.add_item( discord.ui.TextDisplay( - "### Throne\nConnect an account, select a saved creator, rotate its webhook, " - "or skip." + "Connect a Throne creator, select one already saved to your account, rotate " + "its private webhook, or skip this step." ) ) controls = [ @@ -271,7 +367,8 @@ def profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: else: container.add_item( discord.ui.TextDisplay( - "### Review\nYour saved draft is shown above. Edit a section or publish atomically." + "Review the completed sections above. You can edit any section now; " + "nothing becomes public until you choose **Publish**." ) ) edits = [ @@ -298,6 +395,13 @@ def profile_wizard_view(draft: ProfileDraft) -> discord.ui.LayoutView: return view +def _wizard_for( + interaction: discord.Interaction[discord.Client], + draft: ProfileDraft, +) -> discord.ui.LayoutView: + return profile_wizard_view(draft, presentation=member_presentation(interaction.user)) + + async def _load_draft( bot: BillBot, interaction: discord.Interaction[discord.Client], @@ -451,6 +555,17 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No if draft is None: return message = interaction.message + if self.action == "start": + if message is None: + await interaction.response.send_message( + "Please reopen your profile setup with `/profile`.", ephemeral=True + ) + return + await interaction.response.edit_message( + content=None, + view=_wizard_for(interaction, draft), + ) + return if self.action == "publish": try: await bot.require_worker().publish_draft( @@ -522,7 +637,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not save links: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) return if self.action == "throne": await interaction.response.send_modal(ThroneModal(draft, message)) @@ -544,7 +659,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not skip Throne: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) return if self.action == "rotate": try: @@ -568,7 +683,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not rotate that webhook: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) if rotated.webhook_url: await interaction.followup.send( "Your new private Throne webhook URL (save it now):\n" @@ -641,7 +756,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not save that orientation: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) return if self.action.startswith("identity-"): field = self.action.removeprefix("identity-") @@ -659,7 +774,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No ephemeral=True, ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) return message = interaction.message if message is None: @@ -703,7 +818,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not connect that creator: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=profile_wizard_view(updated)) + await interaction.response.edit_message(view=_wizard_for(interaction, updated)) if attached.webhook_url: await interaction.followup.send( "Your private Throne webhook URL (save it now):\n" @@ -921,7 +1036,7 @@ async def save( view=None, ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) await interaction.response.edit_message( content=f"Hidden {len(hidden_ids)} inherited link(s) in this server.", view=None, @@ -993,7 +1108,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N f"Bill could not save identity: {exc}", ephemeral=True ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) await interaction.response.send_message("Identity saved.", ephemeral=True) @@ -1048,7 +1163,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N f"Bill could not save that link: {exc}", ephemeral=True ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) await interaction.response.send_message( "Link saved. Choose **Done** when your links are ready.", ephemeral=True ) @@ -1077,7 +1192,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N f"Bill could not import that page: {exc}", ephemeral=True ) return - await self.message.edit(view=profile_wizard_view(result.draft)) + await self.message.edit(view=_wizard_for(interaction, result.draft)) labels = ( ", ".join( safe_text(candidate.public_label, limit=40) @@ -1128,7 +1243,7 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N f"Bill could not connect that Throne account: {exc}", ephemeral=True ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) text = "Throne connected." if attached.webhook_url: text += ( @@ -1174,7 +1289,7 @@ async def confirm( await interaction.response.edit_message( content=f"Added {result.added_link_count} link(s).", view=None ) - await self.message.edit(view=profile_wizard_view(result.draft)) + await self.message.edit(view=_wizard_for(interaction, result.draft)) @discord.ui.button(label="Not Quite", style=discord.ButtonStyle.secondary) async def manual( @@ -1213,7 +1328,7 @@ async def remove( content=f"Bill could not remove that link: {exc}", view=None ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) await interaction.response.edit_message(content="Link removed.", view=None) @discord.ui.button(label="Prefer payment", style=discord.ButtonStyle.secondary) @@ -1246,7 +1361,7 @@ async def preferred( content=f"Bill could not prefer that link: {exc}", view=None ) return - await self.message.edit(view=profile_wizard_view(updated)) + await self.message.edit(view=_wizard_for(interaction, updated)) await interaction.response.edit_message( content="Preferred payment link updated.", view=None ) @@ -1278,7 +1393,7 @@ async def confirm( ) try: await interaction.user.create_dm() - await interaction.user.dm_channel.send(view=profile_wizard_view(draft)) # type: ignore[union-attr] + await interaction.user.dm_channel.send(view=_wizard_for(interaction, draft)) # type: ignore[union-attr] except discord.Forbidden: return diff --git a/bill/components/public_profile.py b/bill/components/public_profile.py index 8829a54..346faa7 100644 --- a/bill/components/public_profile.py +++ b/bill/components/public_profile.py @@ -4,14 +4,21 @@ import re from typing import TYPE_CHECKING, cast +from urllib.parse import urlsplit import discord -from bill.components.profile import ORIENTATION_LABELS, profile_wizard_view +from bill.components.profile import ( + ORIENTATION_LABELS, + MemberPresentation, + member_presentation, + profile_intro_view, +) from bill.embeds import format_minor_amount from bill.worker_client import ( DraftScope, LinkType, + ProfileLink, PublicProfile, ServerProfileMode, WorkerAPIError, @@ -25,47 +32,113 @@ def _escape(value: str, limit: int = 300) -> str: return discord.utils.escape_mentions(discord.utils.escape_markdown(value))[:limit] +def _profile_section( + presentation: MemberPresentation, + *metadata: str, +) -> discord.ui.Section | discord.ui.TextDisplay: + title = f"### {_escape(presentation.display_name, 80)}" + rows = tuple(discord.ui.TextDisplay(row) for row in metadata) + if presentation.avatar_url: + return discord.ui.Section( + discord.ui.TextDisplay(title), + *rows, + accessory=discord.ui.Thumbnail( + presentation.avatar_url, + description=f"{_escape(presentation.display_name, 80)}'s avatar", + ), + ) + return discord.ui.TextDisplay("\n".join((title, *metadata))) + + +def _blockquote(label: str, values: str) -> str: + return "\n".join(f"> **{label}:** {line}" for line in values.splitlines()) + + +def _is_https_url(value: str) -> bool: + parsed = urlsplit(value) + return parsed.scheme == "https" and bool(parsed.netloc) + + +def profile_links_view( + links: tuple[ProfileLink, ...], + *, + kind: LinkType, + presentation: MemberPresentation, +) -> discord.ui.LayoutView: + """Render one viewer-safe link detail surface with compact HTTPS button rows.""" + title = "Payment Links" if kind is LinkType.PAYMENT else "Socials" + view = discord.ui.LayoutView(timeout=180) + container = discord.ui.Container() + container.add_item(discord.ui.TextDisplay(f"-# Bill Profile · {title}")) + container.add_item( + _profile_section( + presentation, + f"-# {title} shared in this server", + ) + ) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) + buttons = [ + discord.ui.Button(label=_escape(link.public_label, 80), url=link.normalized_url) + for link in links[:12] + if link.link_type is kind and _is_https_url(link.normalized_url) + ] + for index in range(0, len(buttons), 5): + container.add_item(discord.ui.ActionRow(*buttons[index : index + 5])) + view.add_item(container) + return view + + def public_profile_view( profile: PublicProfile, *, guild_id: int | str, owner_view: bool, - display_name: str | None = None, + presentation: MemberPresentation, ) -> discord.ui.LayoutView: """Render public data only; webhook identifiers and URLs never enter this view.""" view = discord.ui.LayoutView(timeout=None) container = discord.ui.Container(accent_color=discord.Color.blurple()) - title = f"{ORIENTATION_LABELS[profile.orientation]} profile" - if display_name: - title = f"{_escape(display_name, 80)} — {title}" - container.add_item(discord.ui.TextDisplay(f"## {title}")) - identity = ", ".join( - ( - *profile.selections.pronouns, - *profile.selections.honourifics, - *profile.selections.submissive_labels, - ) - ) - if identity: - container.add_item(discord.ui.TextDisplay(_escape(identity))) + container.add_item(discord.ui.TextDisplay("-# Bill Profile")) container.add_item( - discord.ui.TextDisplay( - f"**DMs:** {_escape(profile.dm_status.value.replace('_', ' ').title())}" + _profile_section( + presentation, + f"-# Orientation: {_escape(ORIENTATION_LABELS[profile.orientation], 80)}", + f"-# DMs: {_escape(profile.dm_status.value.replace('_', ' ').title(), 40)}", ) ) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) + + identity_rows = [] + for label, values in ( + ("Pronouns", profile.selections.pronouns), + ("Honourifics", profile.selections.honourifics), + ("Submissive labels", profile.selections.submissive_labels), + ): + if values: + identity_rows.append(_blockquote(label, _escape(", ".join(values), 300))) + if identity_rows: + container.add_item(discord.ui.TextDisplay("\n".join(identity_rows))) if profile.bio: - container.add_item(discord.ui.TextDisplay(_escape(profile.bio))) + container.add_item( + discord.ui.TextDisplay( + "\n".join( + f"> {line}" for line in _escape(profile.bio, 300).splitlines() + ) + ) + ) if profile.aliases: - aliases = ", ".join(f"@{alias}" for alias in profile.aliases) - container.add_item(discord.ui.TextDisplay(f"**Aliases:** {_escape(aliases)}")) + aliases = ", ".join(profile.aliases) + container.add_item(discord.ui.TextDisplay(_blockquote("Aliases", _escape(aliases)))) if profile.throne_connected: - container.add_item(discord.ui.TextDisplay("Throne: Connected")) + container.add_item(discord.ui.TextDisplay("> **Throne:** Connected")) if profile.public_send_stats and profile.send_stats: - totals = ", ".join( - f"{format_minor_amount(stat.total_amount_minor, stat.currency)} ({stat.count})" + totals = "\n".join( + f"> **{_escape(stat.currency.upper(), 10)}:** " + f"{_escape(format_minor_amount(stat.total_amount_minor, stat.currency), 80)} " + f"across {stat.count} send{'s' if stat.count != 1 else ''}" for stat in profile.send_stats ) - container.add_item(discord.ui.TextDisplay(f"**Public send stats:** {_escape(totals, 500)}")) + container.add_item(discord.ui.TextDisplay(totals)) controls: list[discord.ui.Button] = [] if any(link.link_type is LinkType.PAYMENT for link in profile.links): controls.append( @@ -92,6 +165,7 @@ def public_profile_view( ) ) if controls: + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) container.add_item(discord.ui.ActionRow(*controls)) view.add_item(container) return view @@ -133,29 +207,33 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No links = ( () if result.profile is None - else tuple(link for link in result.profile.links if link.link_type is self.kind) + else tuple( + link + for link in result.profile.links + if link.link_type is self.kind and _is_https_url(link.normalized_url) + ) ) if not links: await interaction.response.send_message( "There are no public links in this section.", ephemeral=True ) return - view = discord.ui.LayoutView(timeout=180) - container = discord.ui.Container() - container.add_item( - discord.ui.TextDisplay( - f"## {'Payment Links' if self.kind is LinkType.PAYMENT else 'Socials'}" - ) + owner = None + if interaction.guild is not None and str(interaction.guild.id) == self.guild_id: + owner = interaction.guild.get_member(int(self.owner_id)) + presentation = ( + member_presentation(owner) + if owner is not None + else MemberPresentation("Bill member") + ) + await interaction.response.send_message( + view=profile_links_view( + links, + kind=self.kind, + presentation=presentation, + ), + ephemeral=True, ) - # Discord link buttons require a direct HTTPS URL; all URLs were validated by Worker. - for link in links[:12]: - container.add_item( - discord.ui.ActionRow( - discord.ui.Button(label=_escape(link.public_label, 80), url=link.normalized_url) - ) - ) - view.add_item(container) - await interaction.response.send_message(view=view, ephemeral=True) class ProfileEditDynamic( @@ -207,8 +285,11 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No ), ) dm = await interaction.user.create_dm() - await dm.send("Your Bill profile editor is private. Your saved draft is below.") - await dm.send(view=profile_wizard_view(started.draft)) + await dm.send( + "Your Bill profile editor is private. Changes are saved as you go, and " + "nothing is published until you choose **Publish**.", + view=profile_intro_view(started.draft), + ) except discord.Forbidden: await interaction.response.send_message( "I couldn't DM you. Please enable direct messages from server members, " diff --git a/bill/components/setup.py b/bill/components/setup.py index cf3da21..04ec230 100644 --- a/bill/components/setup.py +++ b/bill/components/setup.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from dataclasses import dataclass from typing import TYPE_CHECKING, cast import discord @@ -20,6 +21,31 @@ from bill.bot import BillBot +@dataclass(frozen=True, slots=True) +class GuildPresentation: + guild_name: str + initiator_name: str + icon_url: str | None = None + + +def guild_presentation(guild: object, initiator: object) -> GuildPresentation: + """Read transient Discord names/assets without persisting them in setup state.""" + guild_name = str(getattr(guild, "name", None) or "This server") + initiator_name = str( + getattr(initiator, "display_name", None) + or getattr(initiator, "global_name", None) + or getattr(initiator, "name", None) + or "Server administrator" + ) + icon = getattr(guild, "icon", None) + icon_url = getattr(icon, "url", None) + return GuildPresentation(guild_name, initiator_name, str(icon_url) if icon_url else None) + + +def _escape(value: str, limit: int = 100) -> str: + return discord.utils.escape_mentions(discord.utils.escape_markdown(value))[:limit] + + def missing_channel_permissions(permissions: discord.Permissions) -> tuple[str, ...]: required = { "view_channel": "View Channel", @@ -66,21 +92,53 @@ def setup_custom_id(session: GuildSetupSession, action: str) -> str: return custom_id -def setup_view(session: GuildSetupSession) -> discord.ui.LayoutView: +def setup_view( + session: GuildSetupSession, + *, + presentation: GuildPresentation | None = None, +) -> discord.ui.LayoutView: + presentation = presentation or GuildPresentation("This server", "Server administrator") view = discord.ui.LayoutView(timeout=None) container = discord.ui.Container(accent_color=discord.Color.blurple()) - container.add_item(discord.ui.TextDisplay("## Set up Bill")) + container.add_item(discord.ui.TextDisplay("-# Bill Server Setup")) + title = f"### {_escape(presentation.guild_name)}" + current_step = ( + "Complete" + if session.status == "completed" + else _escape(session.current_step.replace("_", " ").title()) + ) + metadata = ( + f"-# Started by: {_escape(presentation.initiator_name)}", + f"-# Current step: {current_step}", + ) + if presentation.icon_url: + container.add_item( + discord.ui.Section( + discord.ui.TextDisplay(title), + *(discord.ui.TextDisplay(row) for row in metadata), + accessory=discord.ui.Thumbnail( + presentation.icon_url, + description=f"{_escape(presentation.guild_name)} server icon", + ), + ) + ) + else: + container.add_item(discord.ui.TextDisplay("\n".join((title, *metadata)))) + container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small)) if session.status == "completed": container.add_item( discord.ui.TextDisplay( - f"Bill is configured to post sends in <#{session.selected_channel_id}>." + f"> **Posting channel:** <#{session.selected_channel_id}>\n" + "> Bill is ready to post new Throne sends for this server." ) ) elif session.selected_channel_id: container.add_item( - discord.ui.TextDisplay(f"-# Channel: <#{session.selected_channel_id}> (Complete)") + discord.ui.TextDisplay( + f"> **Selected channel:** <#{session.selected_channel_id}>\n" + "> Confirm to make this the public destination for new Throne sends." + ) ) - container.add_item(discord.ui.TextDisplay("### Confirm this channel")) container.add_item( discord.ui.ActionRow( discord.ui.Button( @@ -91,7 +149,12 @@ def setup_view(session: GuildSetupSession) -> discord.ui.LayoutView: ) ) else: - container.add_item(discord.ui.TextDisplay("### Choose where Bill posts Throne sends")) + container.add_item( + discord.ui.TextDisplay( + "Choose the public text channel where Bill should post new Throne sends. " + "You can review the choice before confirming." + ) + ) container.add_item( discord.ui.ActionRow( discord.ui.ChannelSelect( @@ -229,7 +292,12 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not save that channel: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=setup_view(updated)) + await interaction.response.edit_message( + view=setup_view( + updated, + presentation=guild_presentation(interaction.guild, interaction.user), + ) + ) class SetupCompleteDynamic( @@ -324,4 +392,9 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No f"Bill could not complete setup: {exc}", ephemeral=True ) return - await interaction.response.edit_message(view=setup_view(completed.session)) + await interaction.response.edit_message( + view=setup_view( + completed.session, + presentation=guild_presentation(interaction.guild, interaction.user), + ) + ) diff --git a/docs/profiles.md b/docs/profiles.md index 27cdcac..1ac2dcc 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -16,9 +16,11 @@ inherited links while retaining the rest. ## Private setup Missing self profiles start with a normal ephemeral guild prompt. After the -member confirms, Bill sends one normal DM introduction and then a direct -Components V2 wizard. Missing profiles for an explicitly selected other member -never start setup. +member confirms, Bill sends a friendly normal DM introduction with a durable, +user-bound **Start** button. Start replaces the introduction with the Components +V2 wizard; because the control reloads the saved draft from D1, it continues to +work after a bot restart. Missing profiles for an explicitly selected other +member never start setup. Drafts are private D1 records. Each mutation carries the last observed revision; stale, foreign-user, wrong-guild, and completed controls fail safely. @@ -43,10 +45,13 @@ executed. The Worker validates DNS and every redirect, blocks private/reserved destinations, enforces a five-second deadline and 512 KiB body limit, and stores only normalized candidates rather than raw HTML. -Public profile cards do not include webhook URLs, route secrets, creator IDs, -or other credentials. Payment and social buttons ask the Worker for the current -guild-resolved profile and open ephemeral link details for the viewer. A Throne -webhook URL appears only to its owner when first issued or explicitly rotated. +Public profile cards use the member's current Discord display name and avatar, +compact orientation and DM metadata, and only non-empty profile details. They do +not include webhook URLs, route secrets, creator IDs, or other credentials. +Payment and social buttons ask the Worker for the current guild-resolved profile +and open ephemeral link details with up to five direct HTTPS buttons per row. A +Throne webhook URL appears only to its owner when first issued or explicitly +rotated. ## Send statistics diff --git a/tests/test_profiles.py b/tests/test_profiles.py index c8224e9..5402a3d 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import replace +from types import SimpleNamespace from typing import Any import discord @@ -12,14 +13,16 @@ ORIENTATION_LABELS, PROFILE_WIZARD_BUTTON_ACTIONS, PROFILE_WIZARD_SELECT_ACTIONS, + MemberPresentation, ProfileSelectDynamic, ProfileWizardDynamic, _identity_values, + profile_intro_view, profile_wizard_view, wizard_custom_id, ) -from bill.components.public_profile import public_profile_view -from bill.components.setup import setup_custom_id, setup_view +from bill.components.public_profile import profile_links_view, public_profile_view +from bill.components.setup import GuildPresentation, setup_custom_id, setup_view from bill.worker_client import ( CreateLinkImportResult, DmStatus, @@ -35,6 +38,7 @@ ProfileLink, ProfileSelections, PublicProfile, + SendStat, ServerProfileMode, WorkerClient, ) @@ -99,6 +103,61 @@ def draft(*, next_step: DraftStepKey | None = DraftStepKey.ORIENTATION) -> Profi ) +def _all_items(view: discord.ui.LayoutView) -> list[discord.ui.Item[Any]]: + return list(view.walk_children()) + + +def _rows(view: discord.ui.LayoutView) -> list[discord.ui.ActionRow[Any]]: + return [ + item + for item in _all_items(view) + if isinstance(item, discord.ui.ActionRow) + ] + + +def _profile(*, empty: bool = False) -> PublicProfile: + return PublicProfile( + DraftScope.GLOBAL, + None, + "1", + Orientation.SWITCH_DOMME, + DmStatus.OPEN, + None if empty else "@everyone **not markup**", + not empty, + ProfileSelections( + () if empty else ("She/Her",), + () if empty else ("Goddess",), + () if empty else ("Brat",), + ), + () if empty else ("safe_alias",), + () + if empty + else ( + ProfileLink( + "payment", + "Throne", + "Tribute", + None, + "https://throne.com/a", + LinkType.PAYMENT, + ), + ProfileLink( + "social", + "Bluesky", + "Social", + None, + "https://bsky.app/profile/example.com", + LinkType.SOCIAL, + ), + ), + None if empty else "payment", + not empty, + None if empty else (SendStat("USD", 2, 1234), SendStat("EUR", 1, 500)), + 1, + None, + ) + + @pytest.mark.asyncio async def test_profile_lookup_is_parsed_into_frozen_contracts() -> None: payload = { @@ -191,47 +250,295 @@ async def get_updated_draft(*_: object, **__: object) -> ProfileDraft: def test_orientation_wizard_uses_v2_container_and_all_four_options() -> None: - view = profile_wizard_view(draft()) + view = profile_wizard_view( + draft(), + presentation=MemberPresentation("Display Name", "https://cdn.example/avatar.png"), + ) assert isinstance(view, discord.ui.LayoutView) assert len(ORIENTATION_LABELS) == 4 encoded = str(view.to_components()) + assert "-# Bill Profile Setup" in encoded + sections = [ + item for item in _all_items(view) if isinstance(item, discord.ui.Section) + ] + assert len(sections) == 1 + assert isinstance(sections[0].accessory, discord.ui.Thumbnail) assert "bill:p:rdraft_1:1:2:3:orientation" in encoded def test_public_profile_escapes_bio_and_exposes_only_safe_link_controls() -> None: - profile = PublicProfile( - DraftScope.GLOBAL, - None, - "1", - Orientation.DOMME, - DmStatus.OPEN, - "@everyone **not markup**", - False, - ProfileSelections(("She/Her",), (), ()), - (), - (ProfileLink("link", "Throne", "Tribute", None, "https://throne.com/a", LinkType.PAYMENT),), - "link", - True, - None, - 1, - None, + view = public_profile_view( + _profile(), + guild_id=2, + owner_view=True, + presentation=MemberPresentation( + "Display @everyone", + "https://cdn.example/avatar.png", + ), ) - view = public_profile_view(profile, guild_id=2, owner_view=True) - encoded = str(view.to_components()) + assert "-# Bill Profile" in encoded assert "Payment Links" in encoded + assert "Socials" in encoded assert "Edit" in encoded assert "@everyone" not in encoded + assert "Pronouns" in encoded + assert "Honourifics" in encoded + assert "Submissive labels" in encoded + assert "Aliases" in encoded + assert "Throne" in encoded + assert "USD" in encoded and "EUR" in encoded + assert len( + [item for item in _all_items(view) if isinstance(item, discord.ui.Separator)] + ) == 2 + section = next(item for item in _all_items(view) if isinstance(item, discord.ui.Section)) + assert isinstance(section.accessory, discord.ui.Thumbnail) + + +def test_public_profile_hides_empty_sections_and_viewer_edit_control() -> None: + view = public_profile_view( + _profile(empty=True), + guild_id=2, + owner_view=False, + presentation=MemberPresentation("Display Name"), + ) + + encoded = str(view.to_components()) + for hidden in ( + "Pronouns", + "Honourifics", + "Submissive labels", + "Aliases", + "Throne", + "Edit", + ): + assert hidden not in encoded + assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) + assert len( + [item for item in _all_items(view) if isinstance(item, discord.ui.Separator)] + ) == 1 + + +def test_public_profile_viewer_keeps_link_controls_without_owner_edit() -> None: + view = public_profile_view( + _profile(), + guild_id=2, + owner_view=False, + presentation=MemberPresentation("Display Name"), + ) + + encoded = str(view.to_components()) + assert "Payment Links" in encoded and "Socials" in encoded + assert "'label': 'Edit'" not in encoded + + +@pytest.mark.parametrize("kind", [LinkType.PAYMENT, LinkType.SOCIAL]) +def test_link_detail_uses_member_section_and_groups_five_buttons_per_row( + kind: LinkType, +) -> None: + links = tuple( + ProfileLink( + f"link-{index}", + "Platform", + f"Link {index}", + None, + f"https://example.com/{index}", + kind, + ) + for index in range(12) + ) + + view = profile_links_view( + links, + kind=kind, + presentation=MemberPresentation("Display Name", "https://cdn.example/avatar.png"), + ) + + assert [len(row.children) for row in _rows(view)] == [5, 5, 2] + assert any(isinstance(item, discord.ui.Section) for item in _all_items(view)) + assert any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) + assert "<:" not in str(view.to_components()) + assert " None: + links = ( + ProfileLink("bad", "Bad", "Unsafe", None, "http://example.com", LinkType.SOCIAL), + ProfileLink("good", "Good", "Safe", None, "https://example.com", LinkType.SOCIAL), + ) + + view = profile_links_view( + links, + kind=LinkType.SOCIAL, + presentation=MemberPresentation("Bill member"), + ) + + assert [len(row.children) for row in _rows(view)] == [1] + assert "Unsafe" not in str(view.to_components()) + assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) -def test_setup_view_collapses_completed_channel() -> None: +@pytest.mark.parametrize("step", list(DraftStepKey)) +def test_every_profile_wizard_state_has_compact_v2_structure(step: DraftStepKey) -> None: + state = replace( + draft(next_step=step), + current_step=step, + governing_orientation=Orientation.SWITCH_DOMME, + steps=( + DraftStep(DraftStepKey.ORIENTATION, "completed", None), + DraftStep(step, "pending", None), + ), + ) + + view = profile_wizard_view( + state, + presentation=MemberPresentation("Display Name", "https://cdn.example/avatar.png"), + ) + + encoded = str(view.to_components()) + assert "-# Bill Profile Setup" in encoded + assert "Current step" in encoded + assert "Orientation" in encoded + assert any(isinstance(item, discord.ui.Section) for item in _all_items(view)) + assert len(_all_items(view)) <= 40 + assert all(len(row.children) <= 5 for row in _rows(view)) + assert "<:" not in encoded and " None: + view = profile_wizard_view( + draft(), + presentation=MemberPresentation("Display Name"), + ) + + assert "Display Name" in str(view.to_components()) + assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) + + +def test_profile_wizard_collapses_throne_state_without_exposing_creator_id() -> None: + state = replace( + draft(next_step=DraftStepKey.REVIEW), + current_step=DraftStepKey.REVIEW, + governing_orientation=Orientation.DOMME, + steps=(DraftStep(DraftStepKey.THRONE, "completed", None),), + document=replace(draft().document, throne_creator_id="private_creator_id"), + ) + + encoded = str(profile_wizard_view(state).to_components()) + + assert "Throne connected" in encoded + assert "private_creator_id" not in encoded + + +def test_profile_intro_start_control_is_persistent_and_disjoint() -> None: + view = profile_intro_view(draft()) + button = view.children[0] + + assert view.timeout is None + assert isinstance(button, discord.ui.Button) + assert button.label == "Start" + assert button.custom_id == wizard_custom_id(draft(), "start") + assert _matching_profile_dispatchers(button.custom_id) == [ProfileWizardDynamic] + + +@pytest.mark.asyncio +async def test_profile_start_reloads_draft_after_restart_before_rendering() -> None: + loaded: list[tuple[str, int]] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + loaded.append((draft_id, owner_user_id)) + return draft() + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class StartResponse: + def __init__(self) -> None: + self.content: str | None = "unchanged" + self.view: discord.ui.LayoutView | None = None + + async def edit_message( + self, + *, + content: str | None, + view: discord.ui.LayoutView, + ) -> None: + self.content, self.view = content, view + + response = StartResponse() + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + message=object(), + response=response, + ) + item = discord.ui.Button( + label="Start", + custom_id=wizard_custom_id(draft(), "start"), + ) + dynamic = ProfileWizardDynamic(item, "draft_1", "1", "2", 3, "start") + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert loaded == [("draft_1", 1)] + assert response.content is None + assert response.view is not None + assert "-# Bill Profile Setup" in str(response.view.to_components()) + + +@pytest.mark.parametrize( + ("status", "step", "channel_id", "expected"), + [ + ("active", "select_channel", None, "Select a text channel"), + ("active", "confirm", "3", "Confirm setup"), + ("completed", "complete", "3", "Bill is ready"), + ], +) +def test_every_setup_state_has_server_section_and_expected_controls( + status: str, + step: str, + channel_id: str | None, + expected: str, +) -> None: + session = GuildSetupSession( + "setup", "2", "1", status, step, channel_id, 4, None, None, None, None, None + ) + + view = setup_view( + session, + presentation=GuildPresentation( + "Server Name", + "Admin Name", + "https://cdn.example/icon.png", + ), + ) + + encoded = str(view.to_components()) + assert "-# Bill Server Setup" in encoded + assert expected in encoded + section = next(item for item in _all_items(view) if isinstance(item, discord.ui.Section)) + assert isinstance(section.accessory, discord.ui.Thumbnail) + assert all(len(row.children) <= 5 for row in _rows(view)) + assert "<:" not in encoded and " None: session = GuildSetupSession( - "setup", "2", "1", "active", "confirm", "3", 4, None, None, None, None, None + "setup", "2", "1", "active", "select_channel", None, 4, None, None, None, None, None + ) + + view = setup_view( + session, + presentation=GuildPresentation("Server Name", "Admin Name"), ) - assert "Confirm setup" in str(setup_view(session).to_components()) + assert "Server Name" in str(view.to_components()) + assert not any(isinstance(item, discord.ui.Thumbnail) for item in _all_items(view)) @pytest.mark.parametrize( @@ -361,5 +668,8 @@ def test_realistic_persistent_ids_fit_discord_limit() -> None: None, ) - assert len(wizard_custom_id(realistic, "complete-links")) <= 100 + assert all( + len(wizard_custom_id(realistic, action)) <= 100 + for action in (*PROFILE_WIZARD_BUTTON_ACTIONS, *PROFILE_WIZARD_SELECT_ACTIONS) + ) assert len(setup_custom_id(setup, "complete")) <= 100