diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 9b80d12d..7e5605e8 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -711,6 +711,158 @@ async def on_timeout(self) -> None: ) +class MemberAgreementSelectionButton(discord.ui.Button["MemberAgreementSelectionView"]): + """Button for selecting a contact to send the member agreement to.""" + + def __init__(self, contact: dict[str, Any], requester_id: int) -> None: + contact_name = str(contact.get("name", "Unknown")) + label = contact_name[:80] if len(contact_name) > 80 else contact_name + super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="šŸ“„") + self.contact = contact + self.requester_id = requester_id + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle contact selection and send the member agreement.""" + try: + if not self.view: + await interaction.response.send_message( + "āŒ View not found.", + ephemeral=True, + ) + return + if interaction.user.id != self.requester_id: + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="denied", + metadata={ + "reason": "requester_mismatch", + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) + await interaction.response.send_message( + "āŒ Only the command requester can confirm this action.", + ephemeral=True, + ) + return + if not self.view.try_start_selection(): + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="denied", + metadata={ + "reason": "selection_already_processing", + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) + await interaction.response.send_message( + "āš ļø This member agreement selection is already being processed.", + ephemeral=True, + ) + return + + await interaction.response.defer(ephemeral=True) + self.view.disable_controls() + if interaction.message: + try: + await interaction.message.edit(view=self.view) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning( + "Failed to update send_member_agreement selection view: %s", + exc, + ) + + await self.view.crm_cog._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=self.contact, + search_term=self.view.search_term, + ) + except Exception as exc: + logger.error("Error in send_member_agreement selection callback: %s", exc) + if self.view: + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={ + "stage": "selection_callback", + "error": str(exc), + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) + await interaction.followup.send( + "āŒ An error occurred while handling the selection.", + ephemeral=True, + ) + + +class MemberAgreementSelectionView(discord.ui.View): + """View containing contact selection buttons for member agreements.""" + + def __init__( + self, + crm_cog: "CRMCog", + requester_id: int, + search_term: str, + ) -> None: + super().__init__(timeout=300) + self.crm_cog = crm_cog + self.requester_id = requester_id + self.search_term = search_term + self._message: discord.Message | None = None + self._selection_started = False + + def add_contact_button(self, contact: dict[str, Any]) -> None: + """Add a contact selection button.""" + if len(self.children) >= 5: + return + self.add_item( + MemberAgreementSelectionButton( + contact=contact, + requester_id=self.requester_id, + ) + ) + + def set_message(self, message: discord.Message | None) -> None: + """Store the sent message so timeout can disable its controls.""" + self._message = message + + def try_start_selection(self) -> bool: + """Mark this view as processing one selection if it has not started.""" + if self._selection_started: + return False + self._selection_started = True + return True + + def disable_controls(self) -> None: + """Disable all controls in the view.""" + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + + async def on_timeout(self) -> None: + """Disable controls when the selection times out and update the message.""" + self.disable_controls() + if self._message: + try: + await self._message.edit(view=self) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning( + "Failed to disable send_member_agreement selection view: %s", + exc, + ) + + class MarkIdVerifiedSelectionButton(discord.ui.Button["MarkIdVerifiedSelectionView"]): """Button for selecting a contact to mark ID verification on.""" @@ -8669,6 +8821,107 @@ async def _show_invite_outline_user_contact_choices( ) view.set_message(message) + def _member_agreement_choice_contacts( + self, contacts: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Return up to five member-agreement choices, prioritizing unsigned contacts.""" + unsigned_contacts = [ + contact + for contact in contacts + if not self._contact_has_signed_member_agreement(contact) + ] + if not unsigned_contacts: + return contacts[:5] + + signed_contacts = [ + contact + for contact in contacts + if self._contact_has_signed_member_agreement(contact) + ] + choices = unsigned_contacts[:5] + choices.extend(signed_contacts[: max(0, 5 - len(choices))]) + return choices + + async def _show_send_member_agreement_contact_choices( + self, + interaction: discord.Interaction, + *, + search_term: str, + contacts: list[dict[str, Any]], + ) -> None: + """Show contact choices for the member agreement command.""" + embed = discord.Embed( + title="šŸ” Multiple Contacts Found", + description=( + f"Found {len(contacts)} contacts for `{search_term}`. " + "Select the correct person to send the member agreement." + ), + color=0xFFA500, + ) + view = MemberAgreementSelectionView( + crm_cog=self, + requester_id=interaction.user.id, + search_term=search_term, + ) + + displayed_contacts = self._member_agreement_choice_contacts(contacts) + unsigned_count = sum( + 1 + for contact in contacts + if not self._contact_has_signed_member_agreement(contact) + ) + + for i, contact in enumerate(displayed_contacts, 1): + name = contact.get("name", "Unknown") + email = self._contact_preferred_email(contact) or "No email" + contact_id = contact.get("id", "") + signed_at = self._contact_text_value( + contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) + ) + agreement_status = ( + f"āœ… Already signed: `{signed_at}`" + if signed_at + else "šŸ“ Not signed; send/resend allowed" + ) + contact_info = f"šŸ“§ {email}\nšŸ†” ID: `{contact_id}`\n{agreement_status}" + embed.add_field(name=f"{i}. {name}", value=contact_info, inline=True) + if not signed_at: + view.add_contact_button(contact) + + tip_value = ( + "Select an unsigned contact button to continue. " + "Prior send requests are not tracked, so unsigned contacts can be resent." + if unsigned_count + else "All matching contacts already signed. No send buttons are available." + ) + if len(contacts) > len(displayed_contacts): + tip_value += ( + "\nShowing up to 5 matches, prioritizing unsigned contacts. " + "Refine your search for omitted matches." + ) + + embed.add_field( + name="šŸ’” Tip", + value=tip_value, + inline=False, + ) + + has_send_options = len(view.children) > 0 + if has_send_options: + message = await interaction.followup.send( + embed=embed, + view=view, + ephemeral=True, + wait=True, + ) + view.set_message(message) + return + + await interaction.followup.send( + embed=embed, + ephemeral=True, + ) + @app_commands.command( name="mark-id-verified", description="Mark a contact as ID verified (Admin only).", @@ -9009,82 +9262,22 @@ async def invite_outline_user( ephemeral=True, ) - @app_commands.command( - name="send-member-agreement", - description="Send the member agreement for signing to a CRM contact.", - ) - @app_commands.describe( - search_term="Email, 508 email, Discord username, name, or contact ID." - ) - @require_role("Steering Committee") - async def send_member_agreement( + async def _send_member_agreement_for_contact_flow( self, + *, interaction: discord.Interaction, + contact: dict[str, Any], search_term: str, ) -> None: - """Send the member agreement to a contact via DocuSeal.""" - try: - await interaction.response.defer(ephemeral=True) - - contacts = await self._search_contacts_for_lookup( - search_term, - select=( - "id,name,emailAddress,c508Email,cDiscordUsername," - f"{MEMBER_AGREEMENT_SIGNED_AT_FIELD}" - ), - include_discord_user_search=True, - ) - if not contacts: - self._audit_command( - interaction=interaction, - action="crm.send_member_agreement", - result="success", - metadata={"search_term": search_term, "contacts_found": 0}, - ) - await interaction.followup.send( - f"āŒ No contact found for: `{search_term}`", - ephemeral=True, - ) - return - - if len(contacts) > 1: - lines: list[str] = [] - for contact in contacts[:5]: - contact_name = str(contact.get("name") or "Unknown") - contact_id = str(contact.get("id") or "") - display_email = self._contact_preferred_email(contact) or "No email" - lines.append( - f"- **{contact_name}** (`{contact_id}`) - `{display_email}`" - ) - suffix = ( - f"\n...and {len(contacts) - 5} more." if len(contacts) > 5 else "" - ) - self._audit_command( - interaction=interaction, - action="crm.send_member_agreement", - result="success", - metadata={ - "search_term": search_term, - "contacts_found": len(contacts), - "requires_selection": True, - }, - ) - await interaction.followup.send( - "āš ļø Multiple contacts found. Please refine your search:\n" - + "\n".join(lines) - + suffix, - ephemeral=True, - ) - return - - contact = contacts[0] - contact_id = str(contact.get("id") or "").strip() - contact_name = str(contact.get("name") or "Unknown").strip() or "Unknown" - contact_email = self._contact_preferred_email(contact) - signed_at = self._contact_text_value( - contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) - ) + """Send the member agreement for one selected CRM contact.""" + contact_id = str(contact.get("id") or "").strip() + contact_name = str(contact.get("name") or "Unknown").strip() or "Unknown" + contact_email = self._contact_preferred_email(contact) + signed_at = self._contact_text_value( + contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) + ) + try: if self._contact_has_signed_member_agreement(contact): self._audit_command( interaction=interaction, @@ -9188,6 +9381,101 @@ async def send_member_agreement( ephemeral=True, ) + @app_commands.command( + name="send-member-agreement", + description="Send the member agreement for signing to a CRM contact.", + ) + @app_commands.describe( + search_term="Email, 508 email, Discord username, name, or contact ID." + ) + @require_role("Steering Committee") + async def send_member_agreement( + self, + interaction: discord.Interaction, + search_term: str, + ) -> None: + """Send the member agreement to a contact via DocuSeal.""" + try: + await interaction.response.defer(ephemeral=True) + + contacts = await self._search_contacts_for_lookup( + search_term, + select=( + "id,name,emailAddress,c508Email,cDiscordUsername," + f"{MEMBER_AGREEMENT_SIGNED_AT_FIELD}" + ), + include_discord_user_search=True, + ) + if not contacts: + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="success", + metadata={"search_term": search_term, "contacts_found": 0}, + ) + await interaction.followup.send( + f"āŒ No contact found for: `{search_term}`", + ephemeral=True, + ) + return + + if len(contacts) > 1: + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="success", + metadata={ + "search_term": search_term, + "contacts_found": len(contacts), + "requires_selection": True, + }, + ) + await self._show_send_member_agreement_contact_choices( + interaction, + search_term=search_term, + contacts=contacts, + ) + return + + await self._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=contacts[0], + search_term=search_term, + ) + except ValueError as exc: + logger.error("Member agreement command configuration/input error: %s", exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": str(exc)}, + ) + await interaction.followup.send(f"āŒ {exc}", ephemeral=True) + except DocusealAPIError as exc: + logger.error("DocuSeal API error in send_member_agreement: %s", exc) + sanitized_error = self._sanitize_error_message_for_discord(exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": sanitized_error}, + ) + await interaction.followup.send( + f"āŒ DocuSeal API error: {sanitized_error}", ephemeral=True + ) + except Exception as exc: + logger.error("Unexpected error in send_member_agreement: %s", exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": str(exc)}, + ) + await interaction.followup.send( + "āŒ An unexpected error occurred while sending the member agreement.", + ephemeral=True, + ) + @app_commands.command( name="link-discord-user", description="Link a Discord user to a CRM contact (Steering Committee+ only)", diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 443f383a..ac6c9cb4 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -13,6 +13,7 @@ from five08.discord_bot.cogs.crm import ( CRMCog, DiscordLinkOverwriteConfirmationView, + MemberAgreementSelectionView, ResumeButtonView, ResumeConfirmationView, ResumeCreateContactView, @@ -7625,6 +7626,334 @@ async def test_send_member_agreement_requires_contact_email( assert audit_kwargs["result"] == "denied" assert audit_kwargs["metadata"]["reason"] == "missing_email" + @pytest.mark.asyncio + async def test_send_member_agreement_shows_selection_view_for_multiple_contacts( + self, crm_cog, mock_interaction + ): + """Ambiguous contact searches should show buttons for sending to a match.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + "cMemberAgreementSignedAt": None, + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "wilson.kao@example.com", + "cMemberAgreementSignedAt": None, + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + crm_cog._create_member_agreement_submission_for_contact = AsyncMock() + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + crm_cog._create_member_agreement_submission_for_contact.assert_not_awaited() + kwargs = mock_interaction.followup.send.call_args.kwargs + assert kwargs["ephemeral"] is True + assert kwargs["wait"] is True + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Will Gutierrez", "Wilson Kao"] + audit_kwargs = crm_cog._audit_command.call_args.kwargs + assert audit_kwargs["metadata"]["requires_selection"] is True + + @pytest.mark.asyncio + async def test_send_member_agreement_marks_signed_contacts_without_buttons( + self, crm_cog, mock_interaction + ): + """Already signed ambiguous matches should be labeled and not selectable.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + "cMemberAgreementSignedAt": None, + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "wilson.kao@example.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + crm_cog._create_member_agreement_submission_for_contact = AsyncMock() + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Will Gutierrez"] + embed = kwargs["embed"] + field_values = [field.value for field in embed.fields] + assert any("Not signed; send/resend allowed" in value for value in field_values) + assert any( + "Already signed: `2026-03-20 10:00:00`" in value for value in field_values + ) + + @pytest.mark.asyncio + async def test_send_member_agreement_prioritizes_unsigned_contact_after_first_five( + self, crm_cog, mock_interaction + ): + """Eligible unsigned matches after the first five should still be selectable.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": f"signed-{index}", + "name": f"Signed Contact {index}", + "emailAddress": f"signed{index}@example.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + } + for index in range(1, 6) + ] + contacts.append( + { + "id": "unsigned-6", + "name": "Unsigned Contact", + "emailAddress": "unsigned@example.com", + "cMemberAgreementSignedAt": None, + } + ) + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Unsigned Contact"] + embed = kwargs["embed"] + field_names = [field.name for field in embed.fields] + assert "1. Unsigned Contact" in field_names + tip = embed.fields[-1].value + assert "prioritizing unsigned contacts" in tip + + @pytest.mark.asyncio + async def test_send_member_agreement_all_signed_has_no_button_tip( + self, crm_cog, mock_interaction + ): + """All-signed ambiguous matches should not tell users to click buttons.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "wilson.kao@example.com", + "cMemberAgreementSignedAt": "2026-03-21 10:00:00", + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + assert "view" not in kwargs + assert "wait" not in kwargs + assert kwargs["embed"].fields[-1].value == ( + "All matching contacts already signed. No send buttons are available." + ) + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_sends_selected_contact( + self, crm_cog + ): + """Clicking a member agreement selection button should send to that contact.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + + await button.callback(interaction) + + crm_cog._send_member_agreement_for_contact_flow.assert_awaited_once_with( + interaction=interaction, + contact=contact, + search_term="wil", + ) + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + assert all( + item.disabled + for item in view.children + if isinstance(item, discord.ui.Button) + ) + interaction.message.edit.assert_awaited_once_with(view=view) + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_audits_requester_mismatch( + self, crm_cog + ): + """Non-requester button clicks should be denied and audited.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._audit_command_safe = Mock() + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 456 + + await button.callback(interaction) + + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["reason"] == "requester_mismatch" + assert audit_kwargs["resource_id"] == "crm-123" + crm_cog._send_member_agreement_for_contact_flow.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + "āŒ Only the command requester can confirm this action.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_audits_callback_error( + self, crm_cog + ): + """Unexpected button callback errors should be audited.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._audit_command_safe = Mock() + crm_cog._send_member_agreement_for_contact_flow = AsyncMock( + side_effect=RuntimeError("docuseal failed") + ) + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = None + + await button.callback(interaction) + + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "error" + assert audit_kwargs["metadata"]["stage"] == "selection_callback" + assert audit_kwargs["metadata"]["selected_contact_id"] == "crm-123" + interaction.followup.send.assert_awaited_once_with( + "āŒ An error occurred while handling the selection.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_rejects_duplicate_click( + self, crm_cog + ): + """A second member agreement selection should not send another submission.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._audit_command_safe = Mock() + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + assert view.try_start_selection() is True + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.followup = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + + await button.callback(interaction) + + crm_cog._send_member_agreement_for_contact_flow.assert_not_awaited() + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["reason"] == "selection_already_processing" + interaction.response.defer.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + "āš ļø This member agreement selection is already being processed.", + ephemeral=True, + ) + interaction.message.edit.assert_not_awaited() + @pytest.mark.asyncio async def test_send_member_agreement_search_includes_discord_username( self, crm_cog