Skip to content
Merged
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
50 changes: 40 additions & 10 deletions bill/components/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,42 @@
Orientation.SWITCH_DOMME: "Switch (Dom/me lean)",
Orientation.SWITCH_SUBMISSIVE: "Switch (submissive lean)",
}
PROFILE_WIZARD_BUTTON_ACTIONS = (
"publish",
"restart",
"identity",
"links",
"import",
"visibility",
"complete-links",
"skip-links",
"throne",
"skip-throne",
"rotate",
)
PROFILE_WIZARD_SELECT_ACTIONS = (
"orientation",
"identity-pronouns",
"identity-honourifics",
"identity-labels",
"link-select",
"creator-select",
)
_PROFILE_WIZARD_ACTIONS = frozenset(
(*PROFILE_WIZARD_BUTTON_ACTIONS, *PROFILE_WIZARD_SELECT_ACTIONS)
)
_PROFILE_WIZARD_ID_PATTERN = (
r"bill:p:(?P<draft>[A-Za-z0-9_-]+):(?P<owner>[a-z0-9]+):"
r"(?P<guild>[a-z0-9]+):(?P<revision>[a-z0-9]+):"
)
_PROFILE_WIZARD_BUTTON_TEMPLATE = re.compile(
_PROFILE_WIZARD_ID_PATTERN
+ rf"(?P<action>{'|'.join(map(re.escape, PROFILE_WIZARD_BUTTON_ACTIONS))})$"
)
_PROFILE_WIZARD_SELECT_TEMPLATE = re.compile(
_PROFILE_WIZARD_ID_PATTERN
+ rf"(?P<action>{'|'.join(map(re.escape, PROFILE_WIZARD_SELECT_ACTIONS))})$"
)


def safe_text(value: str, *, limit: int = 300) -> str:
Expand All @@ -71,6 +107,8 @@ def safe_text(value: str, *, limit: int = 300) -> str:

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:
raise ValueError(f"Unsupported Bill profile component action: {action}")
custom_id = (
f"bill:p:{encode_resource_id(draft.id)}:{encode_uint(draft.owner_user_id)}:"
f"{encode_uint(draft.origin_guild_id)}:{encode_uint(draft.revision)}:{action}"
Expand Down Expand Up @@ -366,10 +404,7 @@ def __init__(self, draft: ProfileDraft, options: list[discord.SelectOption]) ->

class ProfileWizardDynamic(
discord.ui.DynamicItem[discord.ui.Button],
template=re.compile(
r"bill:p:(?P<draft>[A-Za-z0-9_-]+):(?P<owner>[a-z0-9]+):"
r"(?P<guild>[a-z0-9]+):(?P<revision>[a-z0-9]+):(?P<action>[a-z0-9:_-]+)$"
),
template=_PROFILE_WIZARD_BUTTON_TEMPLATE,
):
"""Persistent action dispatcher; only Worker state decides whether it is valid."""

Expand Down Expand Up @@ -548,12 +583,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No

class _ProfileSelectDynamic(
discord.ui.DynamicItem[discord.ui.Select],
template=re.compile(
r"bill:p:(?P<draft>[A-Za-z0-9_-]+):(?P<owner>[a-z0-9]+):"
r"(?P<guild>[a-z0-9]+):(?P<revision>[a-z0-9]+):"
r"(?P<action>orientation|identity-pronouns|identity-honourifics|"
r"identity-labels|link-select|creator-select)$"
),
template=_PROFILE_WIZARD_SELECT_TEMPLATE,
):
def __init__(
self,
Expand Down
50 changes: 44 additions & 6 deletions bill/components/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING, cast

import discord
from discord import app_commands

from bill.components.custom_ids import (
decode_resource_id,
Expand All @@ -29,6 +30,32 @@ def missing_channel_permissions(permissions: discord.Permissions) -> tuple[str,
return tuple(label for name, label in required.items() if not getattr(permissions, name))


async def _resolve_selected_text_channel(
selected: app_commands.AppCommandChannel | app_commands.AppCommandThread,
*,
guild: discord.Guild,
client: discord.Client,
) -> discord.TextChannel | None:
if (
not isinstance(selected, app_commands.AppCommandChannel)
or selected.guild_id != guild.id
or selected.type is not discord.ChannelType.text
):
return None

channel = guild.get_channel(selected.id)
if channel is None:
channel = await client.fetch_channel(selected.id)

if (
not isinstance(channel, discord.TextChannel)
or channel.guild.id != guild.id
or channel.type is not discord.ChannelType.text
):
return None
return channel


def setup_custom_id(session: GuildSetupSession, action: str) -> str:
custom_id = (
f"bill:s:{encode_resource_id(session.id)}:{encode_uint(session.initiator_user_id)}:"
Expand Down Expand Up @@ -161,12 +188,23 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No
"That setup control is stale. Please use the latest message.", ephemeral=True
)
return
channel = self.item.values[0]
if (
not isinstance(channel, discord.TextChannel)
or interaction.guild is None
or interaction.guild.me is None
):
if interaction.guild is None or interaction.guild.me is None:
await interaction.response.send_message(
"Please choose a standard text channel.", ephemeral=True
)
return
try:
channel = await _resolve_selected_text_channel(
self.item.values[0],
guild=interaction.guild,
client=interaction.client,
)
except discord.HTTPException:
await interaction.response.send_message(
"Bill could not load that channel. Please try again.", ephemeral=True
)
return
if channel is None:
await interaction.response.send_message(
"Please choose a standard text channel.", ephemeral=True
)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

from bill.components.profile import (
ORIENTATION_LABELS,
PROFILE_WIZARD_BUTTON_ACTIONS,
PROFILE_WIZARD_SELECT_ACTIONS,
ProfileSelectDynamic,
ProfileWizardDynamic,
_identity_values,
profile_wizard_view,
wizard_custom_id,
Expand Down Expand Up @@ -299,6 +303,41 @@ def test_setup_custom_id_binds_initiator_guild_and_revision() -> None:
assert len(custom_id) <= 100


def _matching_profile_dispatchers(custom_id: str) -> list[type[discord.ui.DynamicItem]]:
dispatchers = [ProfileWizardDynamic, ProfileSelectDynamic]
return [
dispatcher
for dispatcher in dispatchers
if dispatcher.__discord_ui_compiled_template__.fullmatch(custom_id)
]


@pytest.mark.parametrize("action", PROFILE_WIZARD_SELECT_ACTIONS)
def test_profile_select_actions_match_only_select_dispatcher(action: str) -> None:
custom_id = wizard_custom_id(draft(), action)

assert _matching_profile_dispatchers(custom_id) == [ProfileSelectDynamic]


@pytest.mark.parametrize("action", PROFILE_WIZARD_BUTTON_ACTIONS)
def test_profile_button_actions_match_only_button_dispatcher(action: str) -> None:
custom_id = wizard_custom_id(draft(), action)

assert _matching_profile_dispatchers(custom_id) == [ProfileWizardDynamic]


def test_every_emittable_profile_action_has_exactly_one_dispatcher() -> None:
actions = (*PROFILE_WIZARD_BUTTON_ACTIONS, *PROFILE_WIZARD_SELECT_ACTIONS)

assert len(actions) == len(set(actions))
assert all(
len(_matching_profile_dispatchers(wizard_custom_id(draft(), action))) == 1
for action in actions
)
with pytest.raises(ValueError, match="Unsupported Bill profile component action"):
wizard_custom_id(draft(), "unknown")


def test_realistic_persistent_ids_fit_discord_limit() -> None:
realistic = replace(
draft(),
Expand Down
Loading
Loading