diff --git a/bill/components/profile.py b/bill/components/profile.py index 8ca1ab1..41d634e 100644 --- a/bill/components/profile.py +++ b/bill/components/profile.py @@ -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[A-Za-z0-9_-]+):(?P[a-z0-9]+):" + r"(?P[a-z0-9]+):(?P[a-z0-9]+):" +) +_PROFILE_WIZARD_BUTTON_TEMPLATE = re.compile( + _PROFILE_WIZARD_ID_PATTERN + + rf"(?P{'|'.join(map(re.escape, PROFILE_WIZARD_BUTTON_ACTIONS))})$" +) +_PROFILE_WIZARD_SELECT_TEMPLATE = re.compile( + _PROFILE_WIZARD_ID_PATTERN + + rf"(?P{'|'.join(map(re.escape, PROFILE_WIZARD_SELECT_ACTIONS))})$" +) def safe_text(value: str, *, limit: int = 300) -> str: @@ -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}" @@ -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[A-Za-z0-9_-]+):(?P[a-z0-9]+):" - r"(?P[a-z0-9]+):(?P[a-z0-9]+):(?P[a-z0-9:_-]+)$" - ), + template=_PROFILE_WIZARD_BUTTON_TEMPLATE, ): """Persistent action dispatcher; only Worker state decides whether it is valid.""" @@ -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[A-Za-z0-9_-]+):(?P[a-z0-9]+):" - r"(?P[a-z0-9]+):(?P[a-z0-9]+):" - r"(?Porientation|identity-pronouns|identity-honourifics|" - r"identity-labels|link-select|creator-select)$" - ), + template=_PROFILE_WIZARD_SELECT_TEMPLATE, ): def __init__( self, diff --git a/bill/components/setup.py b/bill/components/setup.py index 3752bc4..cf3da21 100644 --- a/bill/components/setup.py +++ b/bill/components/setup.py @@ -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, @@ -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)}:" @@ -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 ) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 9bb3d57..c8224e9 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -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, @@ -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(), diff --git a/tests/test_setup.py b/tests/test_setup.py index d136f7c..2fd5d2f 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -1,8 +1,157 @@ from __future__ import annotations +from types import SimpleNamespace + import discord +import pytest +from discord import app_commands + +from bill.components.setup import ( + SetupChannelDynamic, + _resolve_selected_text_channel, + missing_channel_permissions, + setup_custom_id, +) +from bill.worker_client import GuildSetupSession + + +class FakeTextChannel: + def __init__(self, channel_id: int, guild_id: int) -> None: + self.id = channel_id + self.guild = SimpleNamespace(id=guild_id) + self.type = discord.ChannelType.text + self.mention = f"<#{channel_id}>" + + def permissions_for(self, _member: object) -> discord.Permissions: + return discord.Permissions.all() + + +class FakeGuild: + def __init__(self, guild_id: int, channel: object | None = None) -> None: + self.id = guild_id + self.me = SimpleNamespace(id=999) + self.channel = channel + self.requested_channel_ids: list[int] = [] + + def get_channel(self, channel_id: int) -> object | None: + self.requested_channel_ids.append(channel_id) + return self.channel + + +class FakeClient: + def __init__( + self, + fetched_channel: object | None = None, + fetch_error: discord.HTTPException | None = None, + ) -> None: + self.fetched_channel = fetched_channel + self.fetch_error = fetch_error + self.fetched_channel_ids: list[int] = [] + + async def fetch_channel(self, channel_id: int) -> object: + self.fetched_channel_ids.append(channel_id) + if self.fetch_error is not None: + raise self.fetch_error + assert self.fetched_channel is not None + return self.fetched_channel + + +class FakeResponse: + def __init__(self) -> None: + self.messages: list[tuple[str, bool]] = [] + self.edited_view: discord.ui.LayoutView | None = None + + async def send_message(self, content: str, *, ephemeral: bool) -> None: + self.messages.append((content, ephemeral)) + + async def edit_message(self, *, view: discord.ui.LayoutView) -> None: + self.edited_view = view -from bill.cogs.setup import missing_channel_permissions + +class FakeWorker: + def __init__(self, session: GuildSetupSession) -> None: + self.session = session + self.saved_channel_id: int | None = None + + async def get_guild_setup(self, _session_id: str) -> GuildSetupSession: + return self.session + + async def set_guild_setup_channel( + self, + _session_id: str, + *, + guild_id: int, + initiator_user_id: int, + expected_revision: int, + channel_id: int, + ) -> GuildSetupSession: + assert (guild_id, initiator_user_id, expected_revision) == (20, 10, 3) + self.saved_channel_id = channel_id + return GuildSetupSession( + "setup", + "20", + "10", + "active", + "confirm", + str(channel_id), + 4, + None, + None, + None, + None, + None, + ) + + +class FakeBot(FakeClient): + def __init__(self, worker: FakeWorker, fetched_channel: object | None = None) -> None: + super().__init__(fetched_channel) + self.worker = worker + + def require_worker(self) -> FakeWorker: + return self.worker + + +def partial_channel( + channel_id: int = 30, + *, + guild_id: int = 20, + channel_type: discord.ChannelType = discord.ChannelType.text, +) -> app_commands.AppCommandChannel: + client = discord.Client(intents=discord.Intents.none()) + return app_commands.AppCommandChannel( + state=client._connection, + data={ + "id": channel_id, + "type": channel_type.value, + "name": "bill-sends", + "permissions": str(discord.Permissions.all().value), + }, + guild_id=guild_id, + ) + + +def partial_thread(channel_id: int = 30, *, guild_id: int = 20) -> app_commands.AppCommandThread: + client = discord.Client(intents=discord.Intents.none()) + return app_commands.AppCommandThread( + state=client._connection, + data={ + "id": channel_id, + "type": discord.ChannelType.public_thread.value, + "name": "bill-thread", + "permissions": str(discord.Permissions.all().value), + "parent_id": "29", + "owner_id": "10", + "member_count": 1, + "message_count": 1, + "thread_metadata": { + "archived": False, + "auto_archive_duration": 60, + "archive_timestamp": "2026-08-22T00:00:00+00:00", + }, + }, + guild_id=guild_id, + ) def test_missing_channel_permissions_lists_actionable_names() -> None: @@ -26,3 +175,150 @@ def test_complete_channel_permissions_passes() -> None: ) assert missing_channel_permissions(permissions) == () + + +@pytest.mark.asyncio +async def test_selected_partial_channel_resolves_from_guild_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = FakeTextChannel(30, 20) + guild = FakeGuild(20, channel) + client = FakeClient() + monkeypatch.setattr("bill.components.setup.discord.TextChannel", FakeTextChannel) + + resolved = await _resolve_selected_text_channel( + partial_channel(), guild=guild, client=client # type: ignore[arg-type] + ) + + assert resolved is channel + assert guild.requested_channel_ids == [30] + assert client.fetched_channel_ids == [] + + +@pytest.mark.asyncio +async def test_selected_partial_channel_fetches_when_not_cached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = FakeTextChannel(30, 20) + guild = FakeGuild(20) + client = FakeClient(channel) + monkeypatch.setattr("bill.components.setup.discord.TextChannel", FakeTextChannel) + + resolved = await _resolve_selected_text_channel( + partial_channel(), guild=guild, client=client # type: ignore[arg-type] + ) + + assert resolved is channel + assert client.fetched_channel_ids == [30] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "selected", + [ + partial_channel(guild_id=21), + partial_channel(channel_type=discord.ChannelType.voice), + partial_channel(channel_type=discord.ChannelType.category), + partial_channel(channel_type=discord.ChannelType.forum), + partial_channel(channel_type=discord.ChannelType.stage_voice), + partial_channel(channel_type=discord.ChannelType.private), + partial_thread(), + ], +) +async def test_selected_partial_channel_rejects_wrong_guild_and_types( + selected: app_commands.AppCommandChannel | app_commands.AppCommandThread, +) -> None: + guild = FakeGuild(20) + client = FakeClient() + + assert ( + await _resolve_selected_text_channel( + selected, guild=guild, client=client # type: ignore[arg-type] + ) + is None + ) + assert guild.requested_channel_ids == [] + assert client.fetched_channel_ids == [] + + +@pytest.mark.asyncio +async def test_selected_partial_channel_rejects_fetched_channel_from_wrong_guild( + monkeypatch: pytest.MonkeyPatch, +) -> None: + guild = FakeGuild(20) + client = FakeClient(FakeTextChannel(30, 21)) + monkeypatch.setattr("bill.components.setup.discord.TextChannel", FakeTextChannel) + + assert ( + await _resolve_selected_text_channel( + partial_channel(), guild=guild, client=client # type: ignore[arg-type] + ) + is None + ) + + +@pytest.mark.asyncio +async def test_channel_callback_surfaces_fetch_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = FakeResponse() + session = GuildSetupSession( + "setup", "20", "10", "active", "select_channel", None, 3, None, None, None, None, None + ) + http_response = SimpleNamespace(status=503, reason="Unavailable", text="Unavailable") + bot = FakeBot(FakeWorker(session)) + bot.fetch_error = discord.HTTPException(http_response, "temporary") # type: ignore[arg-type] + interaction = SimpleNamespace( + client=bot, + guild=FakeGuild(20), + guild_id=20, + user=SimpleNamespace(id=10), + response=response, + ) + select = discord.ui.ChannelSelect(custom_id=setup_custom_id(session, "channel")) + select._values = [partial_channel()] + dynamic = SetupChannelDynamic(select, "setup", "10", "20", 3) + + async def authorized(*_args: object) -> bool: + return True + + monkeypatch.setattr("bill.components.setup._authorized_setup", authorized) + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert response.messages == [ + ("Bill could not load that channel. Please try again.", True) + ] + + +@pytest.mark.asyncio +async def test_channel_callback_saves_resolved_channel_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = FakeResponse() + session = GuildSetupSession( + "setup", "20", "10", "active", "select_channel", None, 3, None, None, None, None, None + ) + worker = FakeWorker(session) + channel = FakeTextChannel(30, 20) + bot = FakeBot(worker, channel) + interaction = SimpleNamespace( + client=bot, + guild=FakeGuild(20), + guild_id=20, + user=SimpleNamespace(id=10), + response=response, + ) + select = discord.ui.ChannelSelect(custom_id=setup_custom_id(session, "channel")) + select._values = [partial_channel()] + dynamic = SetupChannelDynamic(select, "setup", "10", "20", 3) + + async def authorized(*_args: object) -> bool: + return True + + monkeypatch.setattr("bill.components.setup.discord.TextChannel", FakeTextChannel) + monkeypatch.setattr("bill.components.setup._authorized_setup", authorized) + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert worker.saved_channel_id == 30 + assert bot.fetched_channel_ids == [30] + assert response.edited_view is not None