diff --git a/bill/components/profile.py b/bill/components/profile.py index 9850b7e..933187e 100644 --- a/bill/components/profile.py +++ b/bill/components/profile.py @@ -63,6 +63,12 @@ Orientation.SWITCH_DOMME: "Switch (Dom/me lean)", Orientation.SWITCH_SUBMISSIVE: "Switch (submissive lean)", } +DM_STATUS_OPTIONS = ( + (DmStatus.OPEN, "Open", "DMs are welcome"), + (DmStatus.BY_REQUEST, "By Request", "Ask before sending a DM"), + (DmStatus.AFTER_TRIBUTE, "After Tribute", "DMs open after tribute"), + (DmStatus.CLOSED, "Closed", "Not accepting DMs"), +) PROFILE_WIZARD_BUTTON_ACTIONS = ( "start", "publish", @@ -82,6 +88,7 @@ "identity-pronouns", "identity-honourifics", "identity-labels", + "identity-dm-status", "link-select", "creator-select", ) @@ -275,12 +282,23 @@ def profile_wizard_view( ) container.add_item(discord.ui.ActionRow(OrientationSelect(draft))) elif current is DraftStepKey.IDENTITY: + linked = ( + draft.target_scope is DraftScope.SERVER + and draft.server_mode is ServerProfileMode.LINKED + ) container.add_item( discord.ui.TextDisplay( - "Choose the labels you want to show, then add your DM status, optional bio, " - "aliases, and public send-stat preference." + "Choose a DM status and the labels you want to show, then save any optional " + "bio, aliases, and public send-stat preference." + + ( + " Use global setting keeps this server's DM status linked to your global " + "profile." + if linked + else "" + ) ) ) + container.add_item(discord.ui.ActionRow(DmStatusSelect(draft))) container.add_item( discord.ui.ActionRow(IdentitySelect(draft, "pronouns", PRONOUNS, "Choose pronouns")) ) @@ -311,7 +329,7 @@ def profile_wizard_view( discord.ui.ActionRow( _button( draft, - "Save DM status, bio & aliases", + "Save identity details", "identity", discord.ButtonStyle.primary, ) @@ -368,9 +386,11 @@ def profile_wizard_view( container.add_item( discord.ui.TextDisplay( "Review the completed sections above. You can edit any section now; " - "nothing becomes public until you choose **Publish**." + "nothing becomes public until you choose **Publish**. Change your DM status " + "below, including restoring the global setting for a linked profile." ) ) + container.add_item(discord.ui.ActionRow(DmStatusSelect(draft))) edits = [ _button(draft, "Edit identity", "identity", discord.ButtonStyle.secondary), _button(draft, "Edit links", "links", discord.ButtonStyle.secondary), @@ -480,6 +500,45 @@ def __init__( ) +class DmStatusSelect(discord.ui.Select): + def __init__(self, draft: ProfileDraft) -> None: + linked = ( + draft.target_scope is DraftScope.SERVER + and draft.server_mode is ServerProfileMode.LINKED + ) + overridden = set(draft.document.overridden_fields) + inherited = linked and "dm_status" not in overridden + options = [ + discord.SelectOption( + label=label, + value=status.value, + description=description, + default=( + draft.dm_status_selected + and not inherited + and draft.document.dm_status is status + ), + ) + for status, label, description in DM_STATUS_OPTIONS + ] + if linked: + options.append( + discord.SelectOption( + label="Use global setting", + value="inherit", + description="Follow your current global DM status", + default=draft.dm_status_selected and inherited, + ) + ) + super().__init__( + custom_id=wizard_custom_id(draft, "identity-dm-status"), + placeholder="Choose a DM status", + min_values=1, + max_values=1, + options=options, + ) + + class LinkSelect(discord.ui.Select): def __init__(self, draft: ProfileDraft, *, payment: bool) -> None: options = [ @@ -768,7 +827,7 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No expected_revision=draft.revision, values=_partial_identity_values(draft, field, tuple(self.item.values)), ) - except WorkerAPIError as exc: + except (ValueError, WorkerAPIError) as exc: await interaction.response.send_message( f"Bill could not save that identity selection: {exc}", ephemeral=True, @@ -831,7 +890,13 @@ async def callback(self, interaction: discord.Interaction[discord.Client]) -> No def _identity_values( - draft: ProfileDraft, pronouns: str, honourifics: str, labels: str, aliases: str, details: str + draft: ProfileDraft, + pronouns: str, + honourifics: str, + labels: str, + aliases: str, + stats_raw: str, + bio_raw: str, ) -> dict[str, object]: orientation = draft.governing_orientation if orientation is None: @@ -839,16 +904,20 @@ def _identity_values( honourific_available, label_available, aliases_available, _, stats_available = _caps( orientation ) - detail_parts = [part.strip() for part in details.split("|", 2)] - if len(detail_parts) != 3: - raise ValueError("use: DM status | stats on/off | bio") - dm_raw, stats_raw, bio_raw = detail_parts + stats_raw = stats_raw.strip() + bio_raw = bio_raw.strip() linked = ( draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED ) - status = ( - None if linked and dm_raw.casefold() == "inherit" else DmStatus(dm_raw.casefold()).value - ) + existing_overrides = set(draft.document.overridden_fields) + if linked and "dm_status" not in existing_overrides: + status = None + elif draft.document.dm_status is not None: + status = draft.document.dm_status.value + elif linked: + raise ValueError("choose a DM status or Use global setting from the menu") + else: + raise ValueError("choose a DM status from the menu before saving identity") if stats_raw.casefold() == "inherit" and linked: stats: bool | None = None elif stats_raw.casefold() in {"on", "yes", "true"}: @@ -880,14 +949,13 @@ def _identity_values( } if linked: overrides: list[str] = [] - existing_overrides = set(draft.document.overridden_fields) if pronouns.strip() or "pronouns" in existing_overrides: overrides.append("pronouns") if honourific_available and (honourifics.strip() or "honourifics" in existing_overrides): overrides.append("honourifics") if label_available and (labels.strip() or "submissive_labels" in existing_overrides): overrides.append("submissive_labels") - if status is not None: + if "dm_status" in existing_overrides: overrides.append("dm_status") if bio_overridden: overrides.append("bio") @@ -904,35 +972,63 @@ def _partial_identity_values( field: str, selected: tuple[str, ...], ) -> dict[str, object]: - pronouns = selected if field == "pronouns" else draft.document.selections.pronouns - honourifics = selected if field == "honourifics" else draft.document.selections.honourifics - labels = selected if field == "labels" else draft.document.selections.submissive_labels linked = ( draft.target_scope is DraftScope.SERVER and draft.server_mode is ServerProfileMode.LINKED ) overrides = set(draft.document.overridden_fields) - if linked: - overrides.add( - { - "pronouns": "pronouns", - "honourifics": "honourifics", - "labels": "submissive_labels", - }[field] - ) + honourific_available, label_available, aliases_available, _, stats_available = _caps( + draft.governing_orientation + ) + if not honourific_available: + overrides.discard("honourifics") + if not label_available: + overrides.discard("submissive_labels") + if not aliases_available: + overrides.discard("aliases") + if not stats_available: + overrides.discard("public_send_stats") + status = draft.document.dm_status.value if draft.document.dm_status else None + if field == "dm-status": + if len(selected) != 1: + raise ValueError("choose one DM status") + if selected[0] == "inherit": + if not linked: + raise ValueError("only linked profiles can use the global DM setting") + status = None + overrides.discard("dm_status") + else: + try: + status = DmStatus(selected[0]).value + except ValueError as exc: + raise ValueError("choose a valid DM status") from exc + if linked: + overrides.add("dm_status") + elif field in {"pronouns", "honourifics", "labels"}: + if linked: + overrides.add( + { + "pronouns": "pronouns", + "honourifics": "honourifics", + "labels": "submissive_labels", + }[field] + ) + else: + raise ValueError("choose a valid identity field") + pronouns = selected if field == "pronouns" else draft.document.selections.pronouns + honourifics = selected if field == "honourifics" else draft.document.selections.honourifics + labels = selected if field == "labels" else draft.document.selections.submissive_labels values: dict[str, object] = { "pronouns": list(pronouns), "honourifics": list(honourifics), "submissive_labels": list(labels), - "dm_status": ( - draft.document.dm_status.value - if draft.document.dm_status - else (None if linked else DmStatus.OPEN.value) - ), + "dm_status": status, "bio": draft.document.bio, "public_send_stats": draft.document.public_send_stats, "aliases": list(draft.document.aliases), "complete": False, } + if field == "dm-status": + values["dm_status_selected"] = True if linked: values["overrides"] = sorted(overrides) return values @@ -1066,24 +1162,26 @@ def __init__(self, draft: ProfileDraft, message: discord.Message) -> None: and draft.server_mode is ServerProfileMode.LINKED ) overridden = set(draft.document.overridden_fields) - dm_default = ( - draft.document.dm_status.value - if draft.document.dm_status - else ("inherit" if linked else DmStatus.OPEN.value) - ) stats_default = ( "inherit" if linked and "public_send_stats" not in overridden else ("on" if draft.document.public_send_stats else "off") ) - bio_default = draft.document.bio or "-" if not linked or "bio" in overridden else "" - self.details = discord.ui.TextInput( - label="DM status | stats on/off | bio (- clears)", - default=f"{dm_default} | {stats_default} | {bio_default}", + bio_default = (draft.document.bio or "-") if not linked or "bio" in overridden else "" + self.stats = discord.ui.TextInput( + label="Public send stats: on/off/inherit", + default=stats_default, required=True, - max_length=380, + max_length=7, + ) + self.bio = discord.ui.TextInput( + label="Bio (- clears; blank inherits when linked)", + default=bio_default, + required=False, + max_length=300, + style=discord.TextStyle.paragraph, ) - for field in (self.aliases, self.details): + for field in (self.aliases, self.stats, self.bio): self.add_item(field) async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> None: @@ -1100,7 +1198,8 @@ async def on_submit(self, interaction: discord.Interaction[discord.Client]) -> N ", ".join(self.draft.document.selections.honourifics), ", ".join(self.draft.document.selections.submissive_labels), self.aliases.value, - self.details.value, + self.stats.value, + self.bio.value, ), ) except (ValueError, WorkerAPIError) as exc: diff --git a/bill/worker_client.py b/bill/worker_client.py index fe79236..afcdd11 100644 --- a/bill/worker_client.py +++ b/bill/worker_client.py @@ -208,6 +208,7 @@ class ProfileDraft: created_at: str | None updated_at: str | None published_at: str | None + dm_status_selected: bool = False @dataclass(frozen=True, slots=True) @@ -860,6 +861,7 @@ def _parse_draft(value: object) -> ProfileDraft: _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"), ) @staticmethod diff --git a/docs/profiles.md b/docs/profiles.md index 1ac2dcc..db5b54e 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -24,10 +24,17 @@ 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. -Fixed identity selections are persisted without completing the identity step, -so a restart can reconstruct partial progress. Restart is explicit and -revision-checked. Publication changes the public root only during the final -review action. +Fixed identity selections and the DM-status menu are persisted without +completing the identity step, so a restart can reconstruct partial progress. +Global and independent profiles must deliberately choose Open, By Request, +After Tribute, or Closed before saving identity. A linked server profile may +instead choose **Use global setting**, which removes its server-specific DM +status override. That deliberate choice is tracked separately from the saved +status value, so active drafts created before the menu cannot mistake an old +implicit Open value for user intent. The review screen exposes the same +revision-bound menu for changing a status or restoring linked inheritance. +Restart is explicit and revision-checked. Publication changes the public root +only during the final review action. The four orientations are Dom/me, Submissive, Switch leaning Dom/me, and Switch leaning Submissive. All support pronouns, DM status, an optional 300-character diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 5402a3d..59a0087 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -10,13 +10,17 @@ import pytest from bill.components.profile import ( + DM_STATUS_OPTIONS, ORIENTATION_LABELS, PROFILE_WIZARD_BUTTON_ACTIONS, PROFILE_WIZARD_SELECT_ACTIONS, + DmStatusSelect, + IdentityModal, MemberPresentation, ProfileSelectDynamic, ProfileWizardDynamic, _identity_values, + _partial_identity_values, profile_intro_view, profile_wizard_view, wizard_custom_id, @@ -83,6 +87,7 @@ def draft(*, next_step: DraftStepKey | None = DraftStepKey.ORIENTATION) -> Profi current_step=next_step, next_step=next_step, steps=(DraftStep(DraftStepKey.ORIENTATION, "pending", None),), + dm_status_selected=False, governing_orientation=None, document=DraftDocument( None, @@ -103,6 +108,35 @@ def draft(*, next_step: DraftStepKey | None = DraftStepKey.ORIENTATION) -> Profi ) +def identity_draft( + *, + status: DmStatus | None = None, + scope: DraftScope = DraftScope.GLOBAL, + mode: ServerProfileMode | None = None, + overrides: tuple[str, ...] = (), + dm_status_selected: bool | None = None, +) -> ProfileDraft: + state = draft(next_step=DraftStepKey.IDENTITY) + return replace( + state, + target_scope=scope, + guild_id="2" if scope is DraftScope.SERVER else None, + server_mode=mode, + current_step=DraftStepKey.IDENTITY, + dm_status_selected=( + status is not None or mode is ServerProfileMode.LINKED + if dm_status_selected is None + else dm_status_selected + ), + governing_orientation=Orientation.SWITCH_DOMME, + document=replace( + state.document, + dm_status=status, + overridden_fields=overrides, + ), + ) + + def _all_items(view: discord.ui.LayoutView) -> list[discord.ui.Item[Any]]: return list(view.walk_children()) @@ -432,6 +466,31 @@ def test_profile_wizard_collapses_throne_state_without_exposing_creator_id() -> assert "private_creator_id" not in encoded +def test_review_exposes_revision_bound_dm_status_editor_alongside_identity_modal() -> None: + state = replace( + identity_draft(status=DmStatus.OPEN), + current_step=DraftStepKey.REVIEW, + next_step=DraftStepKey.REVIEW, + steps=( + DraftStep(DraftStepKey.IDENTITY, "completed", None), + DraftStep(DraftStepKey.REVIEW, "pending", None), + ), + ) + + items = _all_items(profile_wizard_view(state)) + dm_selects = [item for item in items if isinstance(item, DmStatusSelect)] + edit_buttons = [ + item + for item in items + if isinstance(item, discord.ui.Button) and item.label == "Edit identity" + ] + + assert len(dm_selects) == 1 + assert dm_selects[0].custom_id == wizard_custom_id(state, "identity-dm-status") + assert [option.value for option in dm_selects[0].options if option.default] == ["open"] + assert len(edit_buttons) == 1 + + def test_profile_intro_start_control_is_persistent_and_disjoint() -> None: view = profile_intro_view(draft()) button = view.children[0] @@ -558,12 +617,13 @@ def test_identity_payload_obeys_each_orientation_capability( stats: bool, ) -> None: values = _identity_values( - replace(draft(), governing_orientation=orientation), + replace(identity_draft(status=DmStatus.OPEN), governing_orientation=orientation), "She/Her", ",".join(honourifics), ",".join(labels), ",".join(aliases), - "open | on | hello", + "on", + "hello", ) assert values["honourifics"] == honourifics @@ -573,21 +633,328 @@ def test_identity_payload_obeys_each_orientation_capability( def test_linked_identity_can_inherit_every_field_sparsely() -> None: - linked = replace( - draft(), - target_scope=DraftScope.SERVER, - guild_id="2", - server_mode=ServerProfileMode.LINKED, - governing_orientation=Orientation.SWITCH_DOMME, - ) + linked = identity_draft(scope=DraftScope.SERVER, mode=ServerProfileMode.LINKED) - values = _identity_values(linked, "", "", "", "", "inherit | inherit | ") + values = _identity_values(linked, "", "", "", "", "inherit", "") assert values["overrides"] == [] assert values["dm_status"] is None assert values["bio"] is None +def test_dm_status_menu_has_exact_options_and_no_implicit_global_default() -> None: + select = DmStatusSelect(identity_draft()) + + assert [(option.label, option.value) for option in select.options] == [ + (label, status.value) for status, label, _ in DM_STATUS_OPTIONS + ] + assert not any(option.default for option in select.options) + + +@pytest.mark.parametrize( + ("scope", "mode"), + [ + (DraftScope.GLOBAL, None), + (DraftScope.SERVER, ServerProfileMode.INDEPENDENT), + ], +) +def test_non_linked_dm_status_menu_defaults_only_to_saved_choice( + scope: DraftScope, + mode: ServerProfileMode | None, +) -> None: + select = DmStatusSelect( + identity_draft(status=DmStatus.AFTER_TRIBUTE, scope=scope, mode=mode) + ) + + assert len(select.options) == 4 + assert [option.value for option in select.options if option.default] == ["after_tribute"] + + +def test_legacy_implicit_open_is_not_rendered_as_an_explicit_default() -> None: + select = DmStatusSelect( + identity_draft( + status=DmStatus.OPEN, + dm_status_selected=False, + ) + ) + + assert not any(option.default for option in select.options) + + +def test_linked_dm_status_menu_defaults_to_inheritance_or_explicit_override() -> None: + linked = identity_draft(scope=DraftScope.SERVER, mode=ServerProfileMode.LINKED) + inherited = DmStatusSelect(linked) + overridden = DmStatusSelect( + identity_draft( + status=DmStatus.CLOSED, + scope=DraftScope.SERVER, + mode=ServerProfileMode.LINKED, + overrides=("dm_status",), + ) + ) + + assert [(option.label, option.value) for option in inherited.options][-1] == ( + "Use global setting", + "inherit", + ) + assert [option.value for option in inherited.options if option.default] == ["inherit"] + assert [option.value for option in overridden.options if option.default] == ["closed"] + assert "Use global setting keeps this server's DM status linked" in str( + profile_wizard_view(linked).to_components() + ) + + +def test_other_partial_identity_selection_does_not_default_dm_status() -> None: + values = _partial_identity_values(identity_draft(), "pronouns", ("She/Her",)) + + assert values["pronouns"] == ["She/Her"] + assert values["dm_status"] is None + assert values["complete"] is False + + +def test_dm_status_partial_mutation_preserves_other_identity_fields() -> None: + state = identity_draft() + state = replace( + state, + document=replace( + state.document, + bio="Existing bio", + public_send_stats=True, + selections=ProfileSelections(("They/Them",), ("Goddess",), ("Brat",)), + aliases=("alias",), + ), + ) + + values = _partial_identity_values(state, "dm-status", ("by_request",)) + + assert values == { + "pronouns": ["They/Them"], + "honourifics": ["Goddess"], + "submissive_labels": ["Brat"], + "dm_status": "by_request", + "bio": "Existing bio", + "public_send_stats": True, + "aliases": ["alias"], + "complete": False, + "dm_status_selected": True, + } + + +def test_linked_inherit_partial_removes_only_dm_status_override() -> None: + state = identity_draft( + status=DmStatus.CLOSED, + scope=DraftScope.SERVER, + mode=ServerProfileMode.LINKED, + overrides=("dm_status", "bio"), + ) + + values = _partial_identity_values(state, "dm-status", ("inherit",)) + + assert values["dm_status"] is None + assert values["overrides"] == ["bio"] + assert values["complete"] is False + assert values["dm_status_selected"] is True + + +def test_identity_completion_requires_non_linked_dm_status() -> None: + with pytest.raises(ValueError, match="choose a DM status from the menu"): + _identity_values(identity_draft(), "", "", "", "", "off", "") + + +def test_identity_modal_contains_no_dm_status_or_pipe_delimited_input() -> None: + modal = IdentityModal(identity_draft(status=DmStatus.OPEN), object()) # type: ignore[arg-type] + labels = [item.label for item in modal.children if isinstance(item, discord.ui.TextInput)] + + assert labels == [ + "Aliases, comma separated (- clears)", + "Public send stats: on/off/inherit", + "Bio (- clears; blank inherits when linked)", + ] + assert all("DM status" not in label and "|" not in label for label in labels) + + +@pytest.mark.asyncio +async def test_dm_status_select_persists_revision_bound_partial_mutation() -> None: + state = identity_draft() + calls: list[dict[str, object]] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert (draft_id, owner_user_id) == ("draft_1", 1) + return state + + async def update_draft_step(self, draft_id: str, **kwargs: object) -> ProfileDraft: + calls.append({"draft_id": draft_id, **kwargs}) + return replace( + state, + revision=4, + document=replace(state.document, dm_status=DmStatus.CLOSED), + ) + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class SelectResponse: + async def edit_message(self, *, view: discord.ui.LayoutView) -> None: + assert "Closed" in str(view.to_components()) + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + response=SelectResponse(), + ) + item = discord.ui.Select( + custom_id=wizard_custom_id(state, "identity-dm-status"), + options=[discord.SelectOption(label="Closed", value="closed")], + ) + item._values = ["closed"] + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert calls == [ + { + "draft_id": "draft_1", + "step": DraftStepKey.IDENTITY, + "owner_user_id": 1, + "expected_revision": 3, + "values": { + "pronouns": [], + "honourifics": [], + "submissive_labels": [], + "dm_status": "closed", + "bio": None, + "public_send_stats": False, + "aliases": [], + "complete": False, + "dm_status_selected": True, + }, + } + ] + + +@pytest.mark.asyncio +async def test_review_dm_status_select_restores_linked_inheritance() -> None: + state = replace( + identity_draft( + status=DmStatus.CLOSED, + scope=DraftScope.SERVER, + mode=ServerProfileMode.LINKED, + overrides=("dm_status", "bio", "submissive_labels", "aliases"), + ), + current_step=DraftStepKey.REVIEW, + next_step=DraftStepKey.REVIEW, + governing_orientation=Orientation.DOMME, + ) + calls: list[dict[str, object]] = [] + + class Worker: + async def get_draft(self, draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert (draft_id, owner_user_id) == ("draft_1", 1) + return state + + async def update_draft_step(self, draft_id: str, **kwargs: object) -> ProfileDraft: + calls.append({"draft_id": draft_id, **kwargs}) + return replace( + state, + revision=4, + document=replace( + state.document, + dm_status=None, + overridden_fields=("bio",), + ), + ) + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class SelectResponse: + async def edit_message(self, *, view: discord.ui.LayoutView) -> None: + dm_select = next( + item for item in view.walk_children() if isinstance(item, DmStatusSelect) + ) + assert [option.value for option in dm_select.options if option.default] == ["inherit"] + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1, display_name="Display Name", display_avatar=None), + guild_id=None, + response=SelectResponse(), + ) + item = discord.ui.Select( + custom_id=wizard_custom_id(state, "identity-dm-status"), + options=[discord.SelectOption(label="Use global setting", value="inherit")], + ) + item._values = ["inherit"] + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert calls == [ + { + "draft_id": "draft_1", + "step": DraftStepKey.IDENTITY, + "owner_user_id": 1, + "expected_revision": 3, + "values": { + "pronouns": [], + "honourifics": [], + "submissive_labels": [], + "dm_status": None, + "bio": None, + "public_send_stats": False, + "aliases": [], + "complete": False, + "dm_status_selected": True, + "overrides": ["bio"], + }, + } + ] + + +@pytest.mark.asyncio +async def test_dm_status_select_rejects_stale_revision_before_mutation() -> None: + state = identity_draft() + messages: list[str] = [] + + class Worker: + async def get_draft(self, _draft_id: str, *, owner_user_id: int) -> ProfileDraft: + assert owner_user_id == 1 + return replace(state, revision=4) + + async def update_draft_step(self, *_: object, **__: object) -> ProfileDraft: + raise AssertionError("stale controls must not mutate drafts") + + class Bot: + def require_worker(self) -> Worker: + return Worker() + + class StaleResponse: + async def send_message(self, content: str, *, ephemeral: bool) -> None: + assert ephemeral + messages.append(content) + + interaction = SimpleNamespace( + client=Bot(), + user=SimpleNamespace(id=1), + guild_id=None, + response=StaleResponse(), + ) + item = discord.ui.Select( + custom_id=wizard_custom_id(state, "identity-dm-status"), + options=[discord.SelectOption(label="Closed", value="closed")], + ) + item._values = ["closed"] + dynamic = ProfileSelectDynamic(item, "draft_1", "1", "2", 3, "identity-dm-status") + + await dynamic.callback(interaction) # type: ignore[arg-type] + + assert messages == ["That profile control is stale. Please use the latest wizard message."] + + def test_setup_custom_id_binds_initiator_guild_and_revision() -> None: session = GuildSetupSession( "setup", diff --git a/worker/src/profile/contracts.ts b/worker/src/profile/contracts.ts index e0c969e..e52a9f6 100644 --- a/worker/src/profile/contracts.ts +++ b/worker/src/profile/contracts.ts @@ -192,7 +192,7 @@ export interface IdentityStepInput { readonly pronouns: string[]; readonly honourifics: string[]; readonly submissiveLabels: string[]; - readonly dmStatus: DmStatus; + readonly dmStatus: DmStatus | null; readonly bio: string | null; readonly publicSendStats: boolean; readonly aliases: string[]; @@ -204,7 +204,11 @@ export interface IdentityStepInput { * (and vice versa), and so aliases/stats are only accepted where the * orientation supports them. */ -export function parseIdentityStep(body: unknown, orientation: Orientation): IdentityStepInput { +export function parseIdentityStep( + body: unknown, + orientation: Orientation, + allowUnselectedDmStatus = false, +): IdentityStepInput { const record = asRecord(body, "identity step body"); const caps = ORIENTATION_CAPABILITIES[orientation]; @@ -218,9 +222,12 @@ export function parseIdentityStep(body: unknown, orientation: Orientation): Iden ? parseFixedMultiSelect(record.submissive_labels, SUBMISSIVE_LABELS, "submissive_labels") : (requireEmptyOrAbsent(record.submissive_labels, "submissive_labels", orientation), []); - if (!isDmStatus(record.dm_status)) { + if (record.dm_status !== null && !isDmStatus(record.dm_status)) { fail("invalid_dm_status", `dm_status must be one of: ${DM_STATUSES.join(", ")}`); } + if (record.dm_status === null && !allowUnselectedDmStatus) { + fail("dm_status_required", "dm_status must be chosen before completing identity"); + } const bio = parseOptionalBio(record.bio); @@ -236,7 +243,7 @@ export function parseIdentityStep(body: unknown, orientation: Orientation): Iden pronouns, honourifics, submissiveLabels, - dmStatus: record.dm_status, + dmStatus: record.dm_status as DmStatus | null, bio, publicSendStats, aliases, diff --git a/worker/src/profile/draftService.ts b/worker/src/profile/draftService.ts index 5e31591..36df3bb 100644 --- a/worker/src/profile/draftService.ts +++ b/worker/src/profile/draftService.ts @@ -42,6 +42,8 @@ import { type DocumentSnapshot, } from "./documentStore.js"; +export const DM_STATUS_SELECTION_STEP_KEY = "identity_dm_status_selected"; + export class DraftError extends Error { readonly status: number; readonly code: string; @@ -88,12 +90,12 @@ export async function loadOwnedDraft(env: Env, draftId: string, ownerUserId: str } interface StepStatusRow { - step_key: StepKey; + step_key: string; status: "pending" | "completed"; completed_at: string | null; } -async function loadStepStatuses(env: Env, draftId: string): Promise> { +async function loadStepStatuses(env: Env, draftId: string): Promise> { const { results } = await env.DB.prepare( "SELECT step_key, status, completed_at FROM profile_draft_steps WHERE draft_id = ?", ) @@ -128,6 +130,7 @@ export interface DraftContract { readonly currentStep: StepKey; readonly nextStep: StepKey | null; readonly steps: { key: StepKey; status: "pending" | "completed"; completedAt: string | null }[]; + readonly dmStatusSelected: boolean; readonly governingOrientation: Orientation | null; readonly document: { dmStatus: DocumentSnapshot["dmStatus"]; @@ -203,9 +206,17 @@ export async function buildContract(env: Env, draft: DraftRow): Promise { const found = statuses.get(key); - return { key, status: found?.status ?? ("pending" as const), completedAt: found?.completed_at ?? null }; + const completed = + found?.status === "completed" && (key !== "identity" || dmStatusSelected); + return { + key, + status: completed ? ("completed" as const) : ("pending" as const), + completedAt: completed ? (found?.completed_at ?? null) : null, + }; }); const nextStep = stepList.find((step) => step.status === "pending")?.key ?? null; const thronePrefill = await loadThronePrefill(env, draft, governingOrientation); @@ -223,6 +234,7 @@ export async function buildContract(env: Env, draft: DraftRow): Promise { const linked = draft.target_scope === "server" && draft.server_mode === "linked"; @@ -458,7 +471,11 @@ async function computeNewSnapshot( overriddenFields: Array.from(parsed.overriddenFields), }; } - const parsed = parseIdentityStep(body, governingOrientation); + const parsed = parseIdentityStep( + body, + governingOrientation, + !completeStep && !dmStatusSelected, + ); return { ...current, dmStatus: parsed.dmStatus, @@ -563,10 +580,39 @@ export async function applyDraftStep(env: Env, input: ApplyStepInput): Promise(); + dmStatusPreviouslySelected = marker?.status === "completed"; + if (completeStep && !dmStatusSelected && !dmStatusPreviouslySelected) { + badRequest( + "dm_status_selection_required", + "choose a DM status from the menu before completing identity", + ); + } + } let newSnapshot: DocumentSnapshot; try { - newSnapshot = await computeNewSnapshot(env, draft, current, input.stepKey, governingOrientation, input.body); + newSnapshot = await computeNewSnapshot( + env, + draft, + current, + input.stepKey, + governingOrientation, + input.body, + completeStep, + dmStatusSelected, + ); } catch (error) { if (error instanceof ValidationError) badRequest(error.code, error.message); throw error; @@ -582,6 +628,14 @@ export async function applyDraftStep(env: Env, input: ApplyStepInput): Promise(); + .all<{ step_key: string; status: "pending" | "completed" }>(); const completed = new Set(stepRows.filter((row) => row.status === "completed").map((row) => row.step_key)); const missing = requiredSteps.filter((step) => !completed.has(step)); if (missing.length > 0) { badRequest("steps_incomplete", `the following steps must be completed before publishing: ${missing.join(", ")}`); } - // Completing the `identity` step always sets dm_status (see `parseIdentityStep`/ - // `parseLinkedIdentityStep`), so requiring that step above already guarantees this; - // this is just a defense-in-depth check against a future step-tracking bug. + if (!completed.has(DM_STATUS_SELECTION_STEP_KEY)) { + badRequest( + "dm_status_selection_required", + "a DM status or linked inheritance must be explicitly selected before publishing", + ); + } if (!linked && snapshot.dmStatus === null) { badRequest("dm_status_required", "dm_status must be chosen before publishing"); } diff --git a/worker/src/routes/profileDrafts.ts b/worker/src/routes/profileDrafts.ts index 6a17845..c793647 100644 --- a/worker/src/routes/profileDrafts.ts +++ b/worker/src/routes/profileDrafts.ts @@ -39,6 +39,7 @@ function serializeDraftContract(draft: DraftContract) { current_step: draft.currentStep, next_step: draft.nextStep, steps: draft.steps.map((step) => ({ key: step.key, status: step.status, completed_at: step.completedAt })), + dm_status_selected: draft.dmStatusSelected, governing_orientation: draft.governingOrientation, document: { dm_status: draft.document.dmStatus, diff --git a/worker/src/routes/profileLinkImports.ts b/worker/src/routes/profileLinkImports.ts index b93ea49..8bd055d 100644 --- a/worker/src/routes/profileLinkImports.ts +++ b/worker/src/routes/profileLinkImports.ts @@ -13,6 +13,7 @@ function serializeDraftContract(draft: DraftContract) { current_step: draft.currentStep, next_step: draft.nextStep, steps: draft.steps.map((step) => ({ key: step.key, status: step.status, completed_at: step.completedAt })), + dm_status_selected: draft.dmStatusSelected, document: { links: draft.document.links.map((link) => ({ id: link.id, diff --git a/worker/src/routes/profileLinks.ts b/worker/src/routes/profileLinks.ts index 5cb0120..ae7440d 100644 --- a/worker/src/routes/profileLinks.ts +++ b/worker/src/routes/profileLinks.ts @@ -22,6 +22,7 @@ function serializeDraftContract(draft: DraftContract) { current_step: draft.currentStep, next_step: draft.nextStep, steps: draft.steps.map((step) => ({ key: step.key, status: step.status, completed_at: step.completedAt })), + dm_status_selected: draft.dmStatusSelected, governing_orientation: draft.governingOrientation, document: { dm_status: draft.document.dmStatus, diff --git a/worker/src/routes/profileThrone.ts b/worker/src/routes/profileThrone.ts index 28c42ce..cceb651 100644 --- a/worker/src/routes/profileThrone.ts +++ b/worker/src/routes/profileThrone.ts @@ -12,6 +12,7 @@ function serializeDraftContract(draft: DraftContract) { current_step: draft.currentStep, next_step: draft.nextStep, steps: draft.steps.map((step) => ({ key: step.key, status: step.status, completed_at: step.completedAt })), + dm_status_selected: draft.dmStatusSelected, document: { throne_creator_id: draft.document.throneCreatorId, preferred_payment_link_id: draft.document.preferredPaymentLinkId, diff --git a/worker/test/profileDrafts.test.ts b/worker/test/profileDrafts.test.ts index 31a9256..609bbeb 100644 --- a/worker/test/profileDrafts.test.ts +++ b/worker/test/profileDrafts.test.ts @@ -16,6 +16,7 @@ interface DraftBody { current_step: string; next_step: string | null; steps: { key: string; status: string }[]; + dm_status_selected: boolean; document: Record; } interface ProfileEnvelope { @@ -65,7 +66,7 @@ async function lookup(guildId: string, userId: string) { } describe("profile draft lifecycle (global scope, domme orientation)", () => { - it("persists partial identity selections without completing the step", async () => { + it("persists partial identity selections without choosing a DM status or completing the step", async () => { const owner = "500000000000000090"; const started = await startDraft({ owner_user_id: owner, @@ -86,7 +87,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { pronouns: ["She/Her"], honourifics: [], submissive_labels: [], - dm_status: "open", + dm_status: null, bio: null, public_send_stats: false, aliases: [], @@ -100,14 +101,48 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { honourifics: [], submissive_labels: [], }); + expect(partial.draft?.document.dm_status).toBeNull(); - const completed = await putStep(draftId, "identity", { + const withDmStatus = await putStep(draftId, "identity", { owner_user_id: owner, expected_revision: 2, + complete: false, pronouns: ["She/Her"], honourifics: [], submissive_labels: [], - dm_status: "open", + dm_status: "by_request", + dm_status_selected: true, + bio: null, + public_send_stats: false, + aliases: [], + }); + expect(withDmStatus.status).toBe(200); + expect(withDmStatus.draft?.next_step).toBe("identity"); + expect(withDmStatus.draft?.dm_status_selected).toBe(true); + expect(withDmStatus.draft?.document.dm_status).toBe("by_request"); + expect(withDmStatus.draft?.document.selections).toEqual({ + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + }); + + const resumed = await startDraft({ + owner_user_id: owner, + origin_guild_id: TEST_HOME_GUILD_ID, + target_scope: "global", + }); + expect(resumed.resume_required).toBe(true); + expect(resumed.draft.id).toBe(draftId); + expect(resumed.draft.dm_status_selected).toBe(true); + expect(resumed.draft.document.dm_status).toBe("by_request"); + + const completed = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 3, + pronouns: ["She/Her"], + honourifics: [], + submissive_labels: [], + dm_status: "by_request", bio: null, public_send_stats: false, aliases: [], @@ -142,6 +177,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { pronouns: ["She/Her"], honourifics: ["Goddess"], dm_status: "open", + dm_status_selected: true, bio: "Hello there", public_send_stats: false, }); @@ -299,6 +335,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { owner_user_id: owner, expected_revision: 1, dm_status: "open", + dm_status_selected: true, bio: "must not be written", }); expect(result.status).toBe(409); @@ -347,17 +384,137 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { }); expect(afterOrientation.draft?.current_step).toBe("orientation"); - const restarted = await restart(draftId, { owner_user_id: owner, expected_revision: 1 }); + const selected = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 1, + complete: false, + dm_status: "closed", + dm_status_selected: true, + }); + expect(selected.draft?.dm_status_selected).toBe(true); + + const restarted = await restart(draftId, { owner_user_id: owner, expected_revision: 2 }); expect(restarted.status).toBe(200); - expect(restarted.draft?.revision).toBe(2); + expect(restarted.draft?.revision).toBe(3); expect(restarted.draft?.current_step).toBe("orientation"); expect(restarted.draft?.steps.every((s) => s.status === "pending")).toBe(true); + expect(restarted.draft?.dm_status_selected).toBe(false); expect((restarted.draft?.document as { orientation?: unknown } | undefined)?.orientation).toBeUndefined(); const reread = await getDraft(draftId, owner); + expect(reread.draft?.dm_status_selected).toBe(false); expect(reread.draft?.document.selections).toEqual({ pronouns: [], honourifics: [], submissive_labels: [] }); }); + it.each([ + { + label: "global", + owner: "500000000000000091", + draftId: "legacy-global-draft", + documentId: "legacy-global-document", + targetScope: "global", + guildId: null, + serverMode: null, + originGuildId: TEST_HOME_GUILD_ID, + }, + { + label: "independent", + owner: "500000000000000092", + draftId: "legacy-independent-draft", + documentId: "legacy-independent-document", + targetScope: "server", + guildId: OTHER_GUILD, + serverMode: "independent", + originGuildId: OTHER_GUILD, + }, + ])( + "requires a deliberate status for a legacy $label draft whose Open value was implicit", + async ({ + owner, + draftId, + documentId, + targetScope, + guildId, + serverMode, + originGuildId, + }) => { + const now = new Date().toISOString(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO profile_documents + (id, owner_user_id, state, orientation, dm_status, created_at, updated_at) + VALUES (?, ?, 'draft', 'domme', 'open', ?, ?)`, + ).bind(documentId, owner, now, now), + env.DB.prepare( + `INSERT INTO profile_drafts + (id, owner_user_id, origin_guild_id, target_scope, guild_id, server_mode, + document_id, base_version, status, current_step, revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'active', 'review', 4, ?, ?)`, + ).bind( + draftId, + owner, + originGuildId, + targetScope, + guildId, + serverMode, + documentId, + now, + now, + ), + ...["orientation", "identity", "links", "throne"].map((step) => + env.DB.prepare( + `INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) + VALUES (?, ?, 'completed', ?)`, + ).bind(draftId, step, now), + ), + ]); + + const legacy = await getDraft(draftId, owner); + expect(legacy.draft?.document.dm_status).toBe("open"); + expect(legacy.draft?.dm_status_selected).toBe(false); + expect(legacy.draft?.next_step).toBe("identity"); + expect(legacy.draft?.steps.find((step) => step.key === "identity")?.status).toBe("pending"); + + const implicitCompletion = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 4, + dm_status: "open", + }); + expect(implicitCompletion.status).toBe(400); + expect(implicitCompletion.error?.code).toBe("dm_status_selection_required"); + + const implicitPublish = await publish(draftId, { + owner_user_id: owner, + expected_revision: 4, + }); + expect(implicitPublish.status).toBe(400); + expect(implicitPublish.error?.code).toBe("dm_status_selection_required"); + + const unrelatedPartial = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 4, + complete: false, + pronouns: ["She/Her"], + dm_status: "open", + }); + expect(unrelatedPartial.status).toBe(200); + expect(unrelatedPartial.draft?.dm_status_selected).toBe(false); + expect(unrelatedPartial.draft?.next_step).toBe("identity"); + + const deliberate = await putStep(draftId, "identity", { + owner_user_id: owner, + expected_revision: 5, + complete: false, + pronouns: ["She/Her"], + dm_status: "open", + dm_status_selected: true, + }); + expect(deliberate.status).toBe(200); + expect(deliberate.draft?.dm_status_selected).toBe(true); + expect(deliberate.draft?.document.dm_status).toBe("open"); + }, + ); + it("rejects publish when required steps are incomplete", async () => { const owner = "500000000000000016"; const started = await startDraft({ owner_user_id: owner, origin_guild_id: TEST_HOME_GUILD_ID, target_scope: "global" }); @@ -379,6 +536,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { owner_user_id: owner, expected_revision: 1, dm_status: "open", + dm_status_selected: true, }); await putStep(draftId, "links", { owner_user_id: owner, expected_revision: 2, links: [] }); await putStep(draftId, "throne", { @@ -431,6 +589,7 @@ describe("profile draft lifecycle (global scope, domme orientation)", () => { owner_user_id: owner, expected_revision: 1, dm_status: "open", + dm_status_selected: true, }); await putStep(draftId, "links", { owner_user_id: owner, @@ -511,6 +670,7 @@ describe("profile draft lifecycle (server scope)", () => { honourifics: ["Master"], submissive_labels: ["Pet"], dm_status: "by_request", + dm_status_selected: true, aliases: ["Buddy"], public_send_stats: true, }); @@ -544,6 +704,7 @@ describe("profile draft lifecycle (server scope)", () => { expected_revision: 1, pronouns: ["She/Her"], dm_status: "open", + dm_status_selected: true, bio: "global bio", }); await putStep(globalDraftId, "links", { @@ -578,6 +739,7 @@ describe("profile draft lifecycle (server scope)", () => { expected_revision: 0, overrides: ["dm_status"], dm_status: "closed", + dm_status_selected: true, }); expect(afterIdentity.status).toBe(200); @@ -593,14 +755,28 @@ describe("profile draft lifecycle (server scope)", () => { expect(afterLinks.status).toBe(200); await putStep(linkedDraftId, "review", { owner_user_id: owner, expected_revision: 2 }); - const published = await publish(linkedDraftId, { owner_user_id: owner, expected_revision: 3 }); + const restoredInheritance = await putStep(linkedDraftId, "identity", { + owner_user_id: owner, + expected_revision: 3, + complete: false, + overrides: [], + dm_status: null, + dm_status_selected: true, + }); + expect(restoredInheritance.status).toBe(200); + expect(restoredInheritance.draft?.next_step).toBeNull(); + expect(restoredInheritance.draft?.dm_status_selected).toBe(true); + expect(restoredInheritance.draft?.document.dm_status).toBeNull(); + expect(restoredInheritance.draft?.document.overridden_fields).not.toContain("dm_status"); + + const published = await publish(linkedDraftId, { owner_user_id: owner, expected_revision: 4 }); expect(published.status).toBe(200); expect(published.profile?.mode).toBe("linked"); - expect(published.profile?.dm_status).toBe("closed"); + expect(published.profile?.dm_status).toBe("open"); expect(published.profile?.bio).toBe("global bio"); const looked = await lookup(OTHER_GUILD_2, owner); - expect(looked.profile?.dm_status).toBe("closed"); + expect(looked.profile?.dm_status).toBe("open"); expect(looked.profile?.bio).toBe("global bio"); const links = looked.profile?.links as { platform: string }[]; expect(links.some((l) => l.platform === "onlyfans")).toBe(true); diff --git a/worker/test/profilePublicationRegistration.test.ts b/worker/test/profilePublicationRegistration.test.ts index 7a6d486..5cdab6a 100644 --- a/worker/test/profilePublicationRegistration.test.ts +++ b/worker/test/profilePublicationRegistration.test.ts @@ -28,7 +28,13 @@ async function seedCompletedDraft(options: { ) .bind(options.draftId, options.owner, TEST_HOME_GUILD_ID, options.documentId, now, now) .run(); - for (const step of ["orientation", "identity", "links", "throne"]) { + for (const step of [ + "orientation", + "identity", + "links", + "throne", + "identity_dm_status_selected", + ]) { await env.DB.prepare( `INSERT INTO profile_draft_steps (draft_id, step_key, status, completed_at) VALUES (?, ?, 'completed', ?)`,