Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,719 changes: 1,332 additions & 387 deletions bill/components/profile.py

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions bill/components/public_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ def profile_links_view(
*,
kind: LinkType,
presentation: MemberPresentation,
profile_color: int | None = None,
) -> 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 = discord.ui.Container(
accent_color=None if profile_color is None else discord.Color(profile_color)
)
container.add_item(discord.ui.TextDisplay(f"-# Bill Profile · {title}"))
container.add_item(
_profile_section(
Expand Down Expand Up @@ -97,7 +100,9 @@ def public_profile_view(
) -> 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())
container = discord.ui.Container(
accent_color=None if profile.profile_color is None else discord.Color(profile.profile_color)
)
container.add_item(discord.ui.TextDisplay("-# Bill Profile"))
container.add_item(
_profile_section(
Expand Down Expand Up @@ -231,6 +236,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No
links,
kind=self.kind,
presentation=presentation,
profile_color=result.profile.profile_color,
),
ephemeral=True,
)
Expand Down
13 changes: 9 additions & 4 deletions bill/components/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def setup_view(
) -> 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 = discord.ui.Container()
container.add_item(discord.ui.TextDisplay("-# Bill Server Setup"))
title = f"### {_escape(presentation.guild_name)}"
current_step = (
Expand Down Expand Up @@ -129,16 +129,20 @@ def setup_view(
container.add_item(
discord.ui.TextDisplay(
f"> **Posting channel:** <#{session.selected_channel_id}>\n"
"> Bill is ready to post new Throne sends for this server."
"> Bill is ready to post new Throne sends for this server. Confirming setup "
"does not change any other server settings."
)
)
elif session.selected_channel_id:
container.add_item(
discord.ui.TextDisplay(
f"> **Selected channel:** <#{session.selected_channel_id}>\n"
"> Confirm to make this the public destination for new Throne sends."
"> Confirm to save this as the public destination for new Throne sends. Bill "
"must be able to view the channel, send messages, embed links, and read message "
"history."
)
)
container.add_item(discord.ui.Separator(spacing=discord.SeparatorSpacing.small))
container.add_item(
discord.ui.ActionRow(
discord.ui.Button(
Expand All @@ -152,7 +156,8 @@ def setup_view(
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."
"Bill needs **View Channel**, **Send Messages**, **Embed Links**, and "
"**Read Message History** there. You can review the choice before confirming."
)
)
container.add_item(
Expand Down
136 changes: 128 additions & 8 deletions bill/worker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ class DraftStepKey(StrEnum):
REVIEW = "review"


class WizardStage(StrEnum):
ORIENTATION = "orientation"
PRONOUNS = "pronouns"
HONOURIFICS = "honourifics"
SUBMISSIVE_LABELS = "submissive_labels"
DM_STATUS = "dm_status"
BIO = "bio"
PROFILE_COLOR = "profile_color"
LINKS = "links"
THRONE = "throne"
DETAILS = "details"
REVIEW = "review"


class LinkType(StrEnum):
SOCIAL = "social"
PAYMENT = "payment"
Expand Down Expand Up @@ -147,6 +161,7 @@ class PublicProfile:
send_stats: tuple[SendStat, ...] | None
version: int
published_at: str | None
profile_color: int | None = None


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -174,6 +189,7 @@ class DraftDocument:
hidden_inherited_link_ids: tuple[str, ...]
throne_creator_id: str | None
preferred_payment_link_id: str | None
profile_color: int | None = None


@dataclass(frozen=True, slots=True)
Expand All @@ -188,6 +204,12 @@ class ThronePrefill:
existing_registration_creator_id: str | None


@dataclass(frozen=True, slots=True)
class ThronePending:
handle: str
expires_at: str | None


@dataclass(frozen=True, slots=True)
class ProfileDraft:
id: str
Expand All @@ -209,6 +231,10 @@ class ProfileDraft:
updated_at: str | None
published_at: str | None
dm_status_selected: bool = False
wizard_stage: WizardStage | None = None
wizard_substep: str | None = None
throne_pending: ThronePending | None = None
resolved_profile_color: int | None = None


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -260,6 +286,20 @@ class ThroneDraftResult:
webhook_state: str


@dataclass(frozen=True, slots=True)
class ThroneResolveResult:
draft: ProfileDraft
handle: str
already_verified: bool


@dataclass(frozen=True, slots=True)
class ThroneDraftStatus:
handle: str | None
verified: bool
verified_at: str | None


@dataclass(frozen=True, slots=True)
class GuildSetupSession:
id: str
Expand Down Expand Up @@ -320,6 +360,15 @@ def _integer(value: object, field: str) -> int:
raise WorkerAPIError(f"Worker returned an invalid {field}") from exc


def _optional_color(value: object, field: str = "profile_color") -> int | None:
if value is None:
return None
color = _integer(value, field)
if not 0 <= color <= 0xFFFFFF:
raise WorkerAPIError(f"Worker returned an invalid {field}")
return color


def _enum(enum_type: type[StrEnum], value: object, field: str) -> StrEnum:
try:
return enum_type(_string(value, field))
Expand Down Expand Up @@ -444,6 +493,26 @@ async def update_draft_step(
)
return self._parse_draft(data.get("draft"))

async def set_draft_wizard_stage(
self,
draft_id: str,
*,
owner_user_id: int | str,
expected_revision: int,
stage: WizardStage,
substep: str | None = None,
) -> ProfileDraft:
values: dict[str, JSONValue] = {
"stage": stage.value,
"substep": substep,
}
data = await self._request(
"PUT",
f"/v1/profile-drafts/{draft_id}/wizard-stage",
json=self._mutation(owner_user_id, expected_revision, values),
)
return self._parse_draft(data.get("draft"))

async def restart_draft(
self, draft_id: str, *, owner_user_id: int | str, expected_revision: int
) -> ProfileDraft:
Expand Down Expand Up @@ -593,6 +662,7 @@ async def attach_throne(
expected_revision: int,
throne_input: str | None = None,
existing_creator_id: str | None = None,
confirm_pending: bool = False,
rotate_webhook: bool = False,
) -> ThroneDraftResult:
data = await self._request(
Expand All @@ -604,14 +674,36 @@ async def attach_throne(
{
"throne_input": throne_input,
"existing_creator_id": existing_creator_id,
"confirm_pending": confirm_pending,
"rotate_webhook": rotate_webhook,
},
),
)
return ThroneDraftResult(
await self.get_draft(draft_id, owner_user_id=owner_user_id),
_optional_string(data.get("webhook_url")),
_string(data.get("webhook_state"), "webhook_state"),
return self._parse_throne_result(data)

async def resolve_throne(
self,
draft_id: str,
*,
owner_user_id: int | str,
expected_revision: int,
throne_input: str,
) -> ThroneResolveResult:
data = await self._request(
"POST",
f"/v1/profile-drafts/{draft_id}/throne/resolve",
json=self._mutation(
owner_user_id,
expected_revision,
{"throne_input": throne_input},
),
)
handle = _string(data.get("handle"), "Throne handle")
already_verified = _bool(data.get("already_verified"), "already_verified")
return ThroneResolveResult(
self._parse_draft(data.get("draft")),
handle,
already_verified,
)

async def rotate_throne(
Expand All @@ -622,10 +714,24 @@ async def rotate_throne(
f"/v1/profile-drafts/{draft_id}/throne/rotate",
json=self._mutation(owner_user_id, expected_revision),
)
return ThroneDraftResult(
await self.get_draft(draft_id, owner_user_id=owner_user_id),
_optional_string(data.get("webhook_url")),
_string(data.get("webhook_state"), "webhook_state"),
return self._parse_throne_result(data)

async def get_throne_status(
self,
draft_id: str,
*,
owner_user_id: int | str,
expected_revision: int,
) -> ThroneDraftStatus:
data = await self._request(
"GET",
f"/v1/profile-drafts/{draft_id}/throne/status"
f"?owner_user_id={_snowflake(owner_user_id)}&expected_revision={expected_revision}",
)
return ThroneDraftStatus(
_optional_string(data.get("handle")),
_bool(data.get("verified"), "verified"),
_optional_string(data.get("verified_at")),
)

async def start_guild_setup(
Expand Down Expand Up @@ -813,6 +919,7 @@ def _parse_profile(value: object) -> PublicProfile:
stats,
_integer(data.get("version"), "version"),
_optional_string(data.get("published_at")),
_optional_color(data.get("profile_color")),
)

@staticmethod
Expand All @@ -821,6 +928,14 @@ def _parse_draft(value: object) -> ProfileDraft:
document = _record(data.get("document"), "draft document")
prefill = data.get("throne_prefill")
parsed_prefill = None if prefill is None else WorkerClient._parse_prefill(prefill)
pending = data.get("throne_pending")
parsed_pending = None
if pending is not None:
pending_data = _record(pending, "pending Throne confirmation")
parsed_pending = ThronePending(
_string(pending_data.get("handle"), "pending Throne handle"),
_optional_string(pending_data.get("expires_at")),
)
current = data.get("current_step")
next_step = data.get("next_step")
governing = data.get("governing_orientation")
Expand Down Expand Up @@ -856,12 +971,17 @@ def _parse_draft(value: object) -> ProfileDraft:
_strings(document.get("hidden_inherited_link_ids"), "hidden_inherited_link_ids"),
_optional_string(document.get("throne_creator_id")),
_optional_string(document.get("preferred_payment_link_id")),
_optional_color(document.get("profile_color")),
),
parsed_prefill,
_optional_string(data.get("created_at")),
_optional_string(data.get("updated_at")),
_optional_string(data.get("published_at")),
_bool(data.get("dm_status_selected"), "dm_status_selected"),
_nullable_enum(WizardStage, data.get("wizard_stage"), "wizard_stage"),
_optional_string(data.get("wizard_substep")),
parsed_pending,
_optional_color(data.get("resolved_profile_color"), "resolved_profile_color"),
)

@staticmethod
Expand Down
Loading
Loading