diff --git a/NHCogs/nhmisc/README.md b/NHCogs/nhmisc/README.md index ffb3a31..5f3aa92 100644 --- a/NHCogs/nhmisc/README.md +++ b/NHCogs/nhmisc/README.md @@ -200,8 +200,9 @@ Gate 6 users remain visible but cannot be selected. The action requires Manage Messages and uses a durable one-use source lock. A second message cannot reserve the same member's next Gate while an earlier increment is still pending. Successful users are publicly pinged beside a non-pinging mention of their -new Gate role. Manual Gate role changes are reverted; Gate progress must be changed -through the bot. +new Gate role. The completion message used for the increment is stored as the new Gate's +proof. Manual Gate role changes are reverted; Gate progress must be changed through the +bot. ## Gate Revoke @@ -226,14 +227,16 @@ increment action. ```ini Apps → Add Gate Proof +[p]achievement proof ``` -Use a historical completion message's Apps menu to attach it as proof for Gate records -that were imported without one. The action requires Manage Messages and opens an -ephemeral review for the message author and mentioned members. Each user defaults to -`Don't add proof` and can independently select one of their completed Gate ordinals that -still has no proof. Four users are shown per page, with no total candidate limit imposed -by the action. +Use a historical completion message's Apps menu or pass its link to the text command to +attach it as proof for Gate records that were imported without one. Both entry points +require Manage Messages and open the same review for the message author and mentioned +members. The text command is available only in private moderator channels. Each user +defaults to `Don't add proof` and can independently select one of their completed Gate +ordinals that still has no proof. Four users are shown per page, with no total candidate +limit imposed by the action. Confirmation changes only the selected proof-message references. It does not increment Gate progress or change any roles. The interactive picker only offers Gates without a @@ -260,17 +263,25 @@ attach only missing proofs, or cancel. The entire multi-user operation is atomic invalid target, missing Gate, duplicate user-and-ordinal pair, changed proof, or stale source message prevents every write. +Content beginning with a number and one space is treated as an intended batch. If its +format or Gate assignments are invalid, the review shows the error and lets the moderator +open the same message as a normal Gate proof instead or cancel. The fallback never writes +without the normal review and confirmation. + ## Achievements ```ini +[p]achievement /achievements [user] Apps → View achievements Apps → Grant achievements +[p]achievement proof [p]achievement create [p]achievement list [p]achievement missingproofs [p]achievement rename [p]achievement delete +[p]achievement role [p]achievement role bind @Role [p]achievement role unbind @Role [p]achievement role replace @OldRole @NewRole @@ -278,6 +289,11 @@ Apps → Grant achievements [p]achievement revoke ``` +`achievement` shows a compact overview of the profile, Apps, Gate proof, Gate +increment, Gate revoke, and text-command entry points. `achievement role` shows the +complete syntax and a short description for `bind`, `unbind`, `replace`, and `list`. +Listing the configured bindings remains an explicit `achievement role list` action. + `/achievements` and the user Apps action show the same member profile ephemerally, with the same `Send publicly` button and published attribution. Both are available to every server member. @@ -305,7 +321,8 @@ stable keys required by these commands. Because keys are internal identifiers, ` `achievement missingproofs` reports current non-bot server members who have at least one Gate without a proof link. It previews the first 20 affected members and attaches the full result as CSV, or as a ZIP when needed. The command requires a complete member cache and -is unavailable in channels visible to `@everyone`. +is unavailable in channels visible to `@everyone`. Attach missing proofs with +`achievement proof ` or Apps → Add Gate Proof. Gate increments, proof attachments and revokes, achievement grants and revokes, achievement definition changes, and role binding changes are recorded in the configured diff --git a/NHCogs/nhmisc/achievement_views.py b/NHCogs/nhmisc/achievement_views.py index 0f46abb..d610a92 100644 --- a/NHCogs/nhmisc/achievement_views.py +++ b/NHCogs/nhmisc/achievement_views.py @@ -1091,6 +1091,64 @@ def _configure_controls(self) -> None: self.review.disabled = not self.selected_assignments +class GateProofBatchFallbackView(discord.ui.View): + def __init__( + self, + cog: NHMisc, + source_message: discord.Message, + opener_id: int, + error_message: str, + ) -> None: + super().__init__(timeout=300) + self.cog = cog + self.source_message = source_message + self.opener_id = opener_id + self.error_message = error_message + self.message: discord.Message | None = None + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id == self.opener_id: + return True + await interaction.response.send_message( + "Only the moderator who opened this review can control it", + ephemeral=True, + ) + return False + + async def on_timeout(self) -> None: + for child in self.children: + child.disabled = True + if self.message is not None: + try: + await self.message.edit(view=self) + except discord.HTTPException: + pass + + @discord.ui.button( + label="Use normal Gate proof", + style=discord.ButtonStyle.primary, + ) + async def use_normal( + self, + interaction: discord.Interaction, + _button: discord.ui.Button, + ) -> None: + await self.cog._open_normal_gate_proof_fallback(interaction, self) + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger) + async def cancel( + self, + interaction: discord.Interaction, + _button: discord.ui.Button, + ) -> None: + self.stop() + await interaction.response.edit_message( + content="Gate proof attachment cancelled", + embed=None, + view=None, + ) + + class GateProofBatchView(discord.ui.View): def __init__( self, diff --git a/NHCogs/nhmisc/bot_proxy_manager.py b/NHCogs/nhmisc/bot_proxy_manager.py index 1123336..03900be 100644 --- a/NHCogs/nhmisc/bot_proxy_manager.py +++ b/NHCogs/nhmisc/bot_proxy_manager.py @@ -27,10 +27,8 @@ _get_channel_or_thread, _moderator_mention, ) +from .discord_links import MESSAGE_LINK_PATTERN -MESSAGE_LINK_PATTERN = re.compile( - r"^https://(?:canary\.|ptb\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)$" -) CHANNEL_MENTION_PATTERN = re.compile(r"^<#(\d+)>$") LOG_CONTENT_PREVIEW_LENGTH = 300 log = logging.getLogger(__name__) diff --git a/NHCogs/nhmisc/discord_links.py b/NHCogs/nhmisc/discord_links.py new file mode 100644 index 0000000..6787e59 --- /dev/null +++ b/NHCogs/nhmisc/discord_links.py @@ -0,0 +1,5 @@ +import re + +MESSAGE_LINK_PATTERN = re.compile( + r"^https://(?:canary\.|ptb\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)$" +) diff --git a/NHCogs/nhmisc/nhmisc.py b/NHCogs/nhmisc/nhmisc.py index 90ff997..5008031 100644 --- a/NHCogs/nhmisc/nhmisc.py +++ b/NHCogs/nhmisc/nhmisc.py @@ -52,6 +52,7 @@ UserStats, ) from .bot_proxy_store import BotProxyStore +from .discord_links import MESSAGE_LINK_PATTERN from .forum_autopin import ForumAutopinService from .gate_increment_store import ( GateIncrementMemberPlan, @@ -1851,30 +1852,15 @@ async def _add_gate_proof_context_action( source_message, ): return - members = _build_achievement_candidates(source_message) - if not members: - await interaction.edit_original_response( - content="This message has no eligible recipients" + try: + view = await self._build_normal_gate_proof_view( + interaction.guild, + source_message, + interaction.user.id, ) + except ValueError as error: + await interaction.edit_original_response(content=str(error)) return - missing_by_user = await self._await_achievement_interaction_data( - self._achievement_store.missing_stargate_proofs( - interaction.guild.id, - tuple(member.id for member in members), - ) - ) - candidates = _build_gate_proof_candidates( - source_message, - missing_by_user, - ) - from .achievement_views import GateProofView - - view = GateProofView( - self, - source_message, - interaction.user.id, - candidates, - ) await interaction.edit_original_response( embed=view.render_embed(), view=view, @@ -1889,38 +1875,143 @@ async def _add_gate_proof_context_action( public_defer=False, ) - async def _maybe_open_gate_proof_batch( + async def _build_normal_gate_proof_view( self, - interaction: discord.Interaction, + guild: discord.Guild, source_message: discord.Message, - ) -> bool: + opener_id: int, + ): + members = _build_achievement_candidates(source_message) + if not members: + raise ValueError("This message has no eligible recipients") + missing_by_user = await self._await_achievement_interaction_data( + self._achievement_store.missing_stargate_proofs( + guild.id, + tuple(member.id for member in members), + ) + ) + candidates = _build_gate_proof_candidates( + source_message, + missing_by_user, + ) + from .achievement_views import GateProofView + + return GateProofView( + self, + source_message, + opener_id, + candidates, + ) + + async def _open_normal_gate_proof_fallback( + self, + interaction: discord.Interaction, + fallback_view, + ) -> None: + await interaction.response.defer() try: - entries = _parse_gate_proof_batch( - source_message.content, - expected_guild_id=interaction.guild.id, + view = await self._build_normal_gate_proof_view( + interaction.guild, + fallback_view.source_message, + fallback_view.opener_id, ) except ValueError as error: - await interaction.edit_original_response(content=str(error)) - return True - if entries is None: - return False + await interaction.edit_original_response( + content=str(error), + embed=None, + view=fallback_view, + ) + return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "open normal gate proof fallback", + error, + public_defer=False, + ) + return + fallback_view.stop() + await interaction.edit_original_response( + content=None, + embed=view.render_embed(), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + view.message = interaction.message + + async def _show_gate_proof_batch_fallback( + self, + interaction: discord.Interaction, + source_message: discord.Message, + error_message: str, + ) -> None: + from .achievement_views import GateProofBatchFallbackView + + view = GateProofBatchFallbackView( + self, + source_message, + interaction.user.id, + error_message, + ) + await interaction.edit_original_response( + content=f"Gate proof batch failed\n{error_message}", + embed=None, + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + view.message = await interaction.original_response() + async def _maybe_open_gate_proof_batch( + self, + interaction: discord.Interaction, + source_message: discord.Message, + ) -> bool: try: - entries, members = await self._resolve_gate_proof_batch_members( + view = await self._build_gate_proof_batch_view( interaction.guild, source_message, - entries, + interaction.user.id, ) except ValueError as error: - await interaction.edit_original_response(content=str(error)) + await self._show_gate_proof_batch_fallback( + interaction, + source_message, + str(error), + ) return True + if view is None: + return False + await interaction.edit_original_response( + embed=view.render_embed(), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + view.message = await interaction.original_response() + return True + + async def _build_gate_proof_batch_view( + self, + guild: discord.Guild, + source_message: discord.Message, + opener_id: int, + ): + entries = _parse_gate_proof_batch( + source_message.content, + expected_guild_id=guild.id, + ) + if entries is None: + return None + entries, members = await self._resolve_gate_proof_batch_members( + guild, + source_message, + entries, + ) profiles = {} - error_message = None for member in members.values(): profile = await self._await_achievement_interaction_data( self._achievement_store.get_profile( - interaction.guild.id, + guild.id, member.id, ) ) @@ -1928,21 +2019,17 @@ async def _maybe_open_gate_proof_batch( for entry in entries: profile = profiles[entry.user_id] if entry.ordinal > profile.stargate_count: - error_message = ( + raise ValueError( f"Gate {entry.ordinal} does not exist for <@{entry.user_id}>" ) - break - if error_message is not None: - await interaction.edit_original_response(content=error_message) - return True from .achievement_views import GateProofBatchView requested_keys = {(entry.user_id, entry.ordinal) for entry in entries} - view = GateProofBatchView( + return GateProofBatchView( self, source_message, - interaction.user.id, + opener_id, members, entries, existing_proofs={ @@ -1952,13 +2039,6 @@ async def _maybe_open_gate_proof_batch( if (user_id, proof.ordinal) in requested_keys }, ) - await interaction.edit_original_response( - embed=view.render_embed(), - view=view, - allowed_mentions=discord.AllowedMentions.none(), - ) - view.message = await interaction.original_response() - return True async def _resolve_gate_proof_batch_members( self, @@ -2000,7 +2080,7 @@ async def _resolve_gate_proof_batch_members( if member is None: try: member = await guild.fetch_member(user_id) - except (discord.NotFound, discord.HTTPException): + except discord.NotFound: member = None if member is None or member.bot: raise ValueError( @@ -2033,6 +2113,14 @@ async def _confirm_gate_proofs(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "confirm gate proofs", + error, + public_defer=False, + ) + return current_candidate_ids = tuple( member.id for member in _build_achievement_candidates(source_message) ) @@ -2108,6 +2196,14 @@ async def _confirm_gate_proof_batch( allowed_mentions=discord.AllowedMentions.none(), ) return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "confirm gate proof batch", + error, + public_defer=False, + ) + return current_members: dict[int, discord.Member] = {} try: current_entries = _parse_gate_proof_batch( @@ -2124,6 +2220,14 @@ async def _confirm_gate_proof_batch( ) except ValueError: current_entries = None + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "confirm gate proof batch", + error, + public_defer=False, + ) + return if ( current_entries != view.entries or set(current_members) != set(view.members) @@ -2223,6 +2327,14 @@ async def _confirm_achievement_grant(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "confirm achievement grant", + error, + public_defer=False, + ) + return candidates = _build_achievement_candidates(source_message) if tuple(member.id for member in candidates) != view.candidate_ids: view.source_message = source_message @@ -2347,8 +2459,12 @@ async def achievement(self, ctx: commands.Context) -> None: embed = discord.Embed( title="Achievements", description=( - "View profiles with `/achievements` or Apps → View achievements. " - "Grant achievements from Apps → Grant achievements." + "View profiles with `/achievements` or Apps → View achievements\n" + "Grant achievements with Apps → Grant achievements\n" + "Increment Gates with Apps → Increment Gate roles\n" + "Revoke Gates with `/gaterevoke` or Apps → Revoke Gate\n" + "Attach proofs with Apps → Add Gate Proof or " + f"`{ctx.clean_prefix}achievement proof `" ), ) embed.add_field( @@ -2356,7 +2472,99 @@ async def achievement(self, ctx: commands.Context) -> None: value=self._format_direct_commands(ctx), inline=False, ) - await ctx.send(embed=embed) + await ctx.send( + embed=embed, + allowed_mentions=discord.AllowedMentions.none(), + ) + + @achievement.command(name="proof") + @commands.guild_only() + @commands.has_permissions(manage_messages=True) + async def achievement_proof( + self, + ctx: commands.Context, + message_link: str, + ) -> None: + """Open a review for attaching the linked message as a Gate proof.""" + self._require_private_achievement_channel(ctx) + if not await self._achievement_store.is_bootstrapped(ctx.guild.id): + raise commands.UserFeedbackCheckFailure( + "Achievement data is still initializing. Run " + f"`{ctx.clean_prefix}rolesync discord` first" + ) + source_message = await self._resolve_gate_proof_message_link( + ctx, + message_link, + ) + + try: + view = await self._build_gate_proof_batch_view( + ctx.guild, + source_message, + ctx.author.id, + ) + except ValueError as error: + from .achievement_views import GateProofBatchFallbackView + + view = GateProofBatchFallbackView( + self, + source_message, + ctx.author.id, + str(error), + ) + view.message = await ctx.send( + content=f"Gate proof batch failed\n{error}", + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + return + + if view is None: + try: + view = await self._build_normal_gate_proof_view( + ctx.guild, + source_message, + ctx.author.id, + ) + except ValueError as error: + raise commands.UserFeedbackCheckFailure(str(error)) from error + view.message = await ctx.send( + embed=view.render_embed(), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + + async def _resolve_gate_proof_message_link( + self, + ctx: commands.Context, + value: str, + ) -> discord.Message: + match = MESSAGE_LINK_PATTERN.fullmatch(value) + if match is None or int(match.group(1)) != ctx.guild.id: + raise commands.UserFeedbackCheckFailure( + "The proof message link must be from the current server" + ) + channel_id = int(match.group(2)) + message_id = int(match.group(3)) + channel = ctx.guild.get_channel_or_thread(channel_id) + if channel is None: + try: + channel = await ctx.guild.fetch_channel(channel_id) + except (discord.NotFound, discord.Forbidden) as error: + raise commands.UserFeedbackCheckFailure( + "The proof message channel is unavailable" + ) from error + fetch_message = getattr(channel, "fetch_message", None) + if not callable(fetch_message): + raise commands.UserFeedbackCheckFailure( + "The proof message channel is unavailable" + ) + try: + return await fetch_message(message_id) + except (discord.NotFound, discord.Forbidden) as error: + raise commands.UserFeedbackCheckFailure( + "The proof message is unavailable" + ) from error @achievement.command(name="create") @commands.guild_only() @@ -2608,7 +2816,23 @@ async def _confirm_achievement_delete( @commands.has_permissions(manage_messages=True) async def achievement_role(self, ctx: commands.Context) -> None: """Manage optional Discord role bindings for achievements.""" - await self.achievement_role_list.callback(self, ctx) + embed = discord.Embed( + title="Achievement roles", + description="Manage optional Discord role bindings for achievements", + ) + embed.add_field( + name="Commands", + value=self._format_direct_commands( + ctx, + preferred_order=("bind", "unbind", "replace", "list"), + include_descriptions=True, + ), + inline=False, + ) + await ctx.send( + embed=embed, + allowed_mentions=discord.AllowedMentions.none(), + ) @achievement_role.command(name="bind") @commands.guild_only() @@ -3740,6 +3964,7 @@ def _format_direct_commands( *, preferred_order: tuple[str, ...] = (), expand_singletons: bool = False, + include_descriptions: bool = False, ) -> str: order = {name: index for index, name in enumerate(preferred_order)} commands_to_render = sorted( @@ -3775,6 +4000,10 @@ def _format_direct_commands( if signature: usage = f"{usage} {signature}" lines.append(f"`{usage}`") + if include_descriptions: + description = rendered_command.short_doc.strip() + if description: + lines.append(description) return "\n".join(lines) or "No subcommands available." @staticmethod @@ -4630,6 +4859,14 @@ async def _resume_gate_increment_review(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "resume gate increment review", + error, + public_defer=False, + ) + return await self._finish_gate_increment_review( interaction, view, @@ -4653,6 +4890,14 @@ async def _refresh_gate_increment_review(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "refresh gate increment review", + error, + public_defer=False, + ) + return view.source_message = source_message view.replace_candidates(candidates) await interaction.edit_original_response( @@ -4662,6 +4907,35 @@ async def _refresh_gate_increment_review(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) + async def _prepare_gate_increment_confirmation(self, interaction, view): + try: + source_message = await self._fetch_gate_increment_source( + self._gate_increment_key(view.source_message) + ) + _validate_gate_increment_configuration(source_message.guild) + live_candidates = await self._fetch_gate_increment_candidates( + source_message + ) + self._validate_gate_increment_candidate_count(live_candidates) + await self._require_private_moderation_log_channel(source_message.guild) + except commands.UserFeedbackCheckFailure as error: + await interaction.edit_original_response( + content=None, + embed=view.render_embed(notice=str(error)), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + return None + except Exception as error: + await self._handle_achievement_interaction_failure( + interaction, + "confirm gate increment review", + error, + public_defer=False, + ) + return None + return source_message, live_candidates + async def _confirm_gate_increment_review(self, interaction, view) -> None: await interaction.response.defer() if not await self._achievement_store.is_bootstrapped( @@ -4683,24 +4957,10 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return - try: - source_message = await self._fetch_gate_increment_source( - self._gate_increment_key(view.source_message) - ) - _validate_gate_increment_configuration(source_message.guild) - live_candidates = await self._fetch_gate_increment_candidates( - source_message - ) - self._validate_gate_increment_candidate_count(live_candidates) - await self._require_private_moderation_log_channel(source_message.guild) - except commands.UserFeedbackCheckFailure as error: - await interaction.edit_original_response( - content=None, - embed=view.render_embed(notice=str(error)), - view=view, - allowed_mentions=discord.AllowedMentions.none(), - ) + prepared = await self._prepare_gate_increment_confirmation(interaction, view) + if prepared is None: return + source_message, live_candidates = prepared if self._gate_increment_review_is_stale(view, live_candidates): view.source_message = source_message @@ -5170,7 +5430,7 @@ async def _fetch_gate_increment_source( ) try: return await channel.fetch_message(key.message_id) - except (discord.NotFound, discord.Forbidden, discord.HTTPException) as error: + except (discord.NotFound, discord.Forbidden) as error: raise commands.UserFeedbackCheckFailure( "The source message is unavailable" ) from error diff --git a/tests/test_gate_proof_flow.py b/tests/test_gate_proof_flow.py index 62306cc..1bd8549 100644 --- a/tests/test_gate_proof_flow.py +++ b/tests/test_gate_proof_flow.py @@ -344,6 +344,38 @@ def test_author_and_mentions_keep_individual_missing_gate_ordinals(self): class GateProofViewTests(unittest.IsolatedAsyncioTestCase): + async def test_failed_batch_fallback_is_limited_to_its_opener(self): + views, _fake_select = _load_achievement_views() + cog = SimpleNamespace(_open_normal_gate_proof_fallback=mock.AsyncMock()) + source_message = SimpleNamespace() + view = views.GateProofBatchFallbackView( + cog, + source_message, + opener_id=99, + error_message="Invalid Gate proof batch line 1", + ) + rejected = SimpleNamespace( + user=SimpleNamespace(id=100), + response=SimpleNamespace(send_message=mock.AsyncMock()), + ) + + self.assertFalse(await view.interaction_check(rejected)) + rejected.response.send_message.assert_awaited_once_with( + "Only the moderator who opened this review can control it", + ephemeral=True, + ) + + accepted = SimpleNamespace( + user=SimpleNamespace(id=99), + response=SimpleNamespace(), + ) + await view.use_normal.callback(accepted) + + cog._open_normal_gate_proof_fallback.assert_awaited_once_with( + accepted, + view, + ) + def test_four_users_per_page_preserves_individual_default_and_selection(self): views, fake_select = _load_achievement_views() candidates = tuple( @@ -533,6 +565,204 @@ async def test_batch_replacement_buttons_select_the_requested_write_mode(self): class GateProofEntryPointTests(unittest.IsolatedAsyncioTestCase): + async def test_text_command_checks_private_channel_before_proof_work(self): + cog = object.__new__(nhmisc.NHMisc) + cog._require_private_achievement_channel = mock.Mock( + side_effect=RuntimeError("public channel") + ) + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock() + ) + cog._resolve_gate_proof_message_link = mock.AsyncMock() + guild = SimpleNamespace(id=1) + ctx = SimpleNamespace(guild=guild, channel=SimpleNamespace(), send=mock.AsyncMock()) + + with self.assertRaisesRegex(RuntimeError, "public channel"): + await nhmisc.NHMisc.achievement_proof( + cog, + ctx, + "https://discord.com/channels/1/20/30", + ) + + cog._achievement_store.is_bootstrapped.assert_not_awaited() + cog._resolve_gate_proof_message_link.assert_not_awaited() + + async def test_text_command_resolves_an_uncached_archived_thread(self): + source_message = SimpleNamespace() + thread = SimpleNamespace(fetch_message=mock.AsyncMock(return_value=source_message)) + guild = SimpleNamespace( + id=1, + get_channel_or_thread=mock.Mock(return_value=None), + fetch_channel=mock.AsyncMock(return_value=thread), + ) + cog = object.__new__(nhmisc.NHMisc) + ctx = SimpleNamespace(guild=guild) + + resolved = await cog._resolve_gate_proof_message_link( + ctx, + "https://discord.com/channels/1/20/30", + ) + + self.assertIs(resolved, source_message) + guild.fetch_channel.assert_awaited_once_with(20) + thread.fetch_message.assert_awaited_once_with(30) + + async def test_text_command_rejects_a_message_from_another_guild(self): + guild = SimpleNamespace( + id=1, + get_channel_or_thread=mock.Mock(), + fetch_channel=mock.AsyncMock(), + ) + cog = object.__new__(nhmisc.NHMisc) + ctx = SimpleNamespace(guild=guild) + + with self.assertRaisesRegex( + nhmisc.commands.UserFeedbackCheckFailure, + "current server", + ): + await cog._resolve_gate_proof_message_link( + ctx, + "https://discord.com/channels/2/20/30", + ) + + guild.get_channel_or_thread.assert_not_called() + guild.fetch_channel.assert_not_awaited() + + async def test_text_command_opens_the_shared_normal_review(self): + guild = SimpleNamespace(id=1) + source_message = SimpleNamespace(guild=guild) + sent_message = SimpleNamespace() + ctx = SimpleNamespace( + guild=guild, + author=SimpleNamespace(id=99), + send=mock.AsyncMock(return_value=sent_message), + ) + view = SimpleNamespace(render_embed=mock.Mock(return_value="normal"), message=None) + cog = object.__new__(nhmisc.NHMisc) + cog._require_private_achievement_channel = mock.Mock() + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock(return_value=True) + ) + cog._resolve_gate_proof_message_link = mock.AsyncMock( + return_value=source_message + ) + cog._build_gate_proof_batch_view = mock.AsyncMock(return_value=None) + cog._build_normal_gate_proof_view = mock.AsyncMock(return_value=view) + + await nhmisc.NHMisc.achievement_proof( + cog, + ctx, + "https://discord.com/channels/1/20/30", + ) + + cog._build_gate_proof_batch_view.assert_awaited_once_with( + guild, + source_message, + 99, + ) + cog._build_normal_gate_proof_view.assert_awaited_once_with( + guild, + source_message, + 99, + ) + ctx.send.assert_awaited_once_with( + embed="normal", + view=view, + allowed_mentions="no-mentions", + ) + self.assertIs(view.message, sent_message) + + async def test_text_command_opens_the_shared_batch_review(self): + guild = SimpleNamespace(id=1) + source_message = SimpleNamespace(guild=guild) + sent_message = SimpleNamespace() + ctx = SimpleNamespace( + guild=guild, + author=SimpleNamespace(id=99), + send=mock.AsyncMock(return_value=sent_message), + ) + view = SimpleNamespace(render_embed=mock.Mock(return_value="batch"), message=None) + cog = object.__new__(nhmisc.NHMisc) + cog._require_private_achievement_channel = mock.Mock() + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock(return_value=True) + ) + cog._resolve_gate_proof_message_link = mock.AsyncMock( + return_value=source_message + ) + cog._build_gate_proof_batch_view = mock.AsyncMock(return_value=view) + cog._build_normal_gate_proof_view = mock.AsyncMock() + + await nhmisc.NHMisc.achievement_proof( + cog, + ctx, + "https://discord.com/channels/1/20/30", + ) + + cog._build_gate_proof_batch_view.assert_awaited_once_with( + guild, + source_message, + 99, + ) + cog._build_normal_gate_proof_view.assert_not_awaited() + ctx.send.assert_awaited_once_with( + embed="batch", + view=view, + allowed_mentions="no-mentions", + ) + self.assertIs(view.message, sent_message) + + async def test_text_command_offers_fallback_for_an_invalid_batch(self): + guild = SimpleNamespace(id=1) + source_message = SimpleNamespace(guild=guild) + sent_message = SimpleNamespace() + ctx = SimpleNamespace( + guild=guild, + author=SimpleNamespace(id=99), + send=mock.AsyncMock(return_value=sent_message), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._require_private_achievement_channel = mock.Mock() + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock(return_value=True) + ) + cog._resolve_gate_proof_message_link = mock.AsyncMock( + return_value=source_message + ) + cog._build_gate_proof_batch_view = mock.AsyncMock( + side_effect=ValueError("Invalid Gate proof batch line 2") + ) + + class FakeFallbackView: + def __init__(self, cog, source_message, opener_id, error_message): + self.error_message = error_message + self.message = None + + package = ModuleType("_gatecount_root.nhmisc") + package.__path__ = [] + views = ModuleType("_gatecount_root.nhmisc.achievement_views") + views.GateProofBatchFallbackView = FakeFallbackView + with mock.patch.dict( + sys.modules, + { + "_gatecount_root.nhmisc": package, + "_gatecount_root.nhmisc.achievement_views": views, + }, + ): + await nhmisc.NHMisc.achievement_proof( + cog, + ctx, + "https://discord.com/channels/1/20/30", + ) + + kwargs = ctx.send.await_args.kwargs + self.assertEqual( + kwargs["content"], + "Gate proof batch failed\nInvalid Gate proof batch line 2", + ) + self.assertIsInstance(kwargs["view"], FakeFallbackView) + self.assertIs(kwargs["view"].message, sent_message) + async def test_batch_targets_resolve_cached_and_uncached_current_members(self): cached = SimpleNamespace(id=11, bot=False) fetched = SimpleNamespace(id=12, bot=False) @@ -560,6 +790,93 @@ async def test_batch_targets_resolve_cached_and_uncached_current_members(self): self.assertEqual(tuple(members), (11, 12)) guild.fetch_member.assert_awaited_once_with(12) + async def test_batch_member_http_failure_remains_operational(self): + guild = SimpleNamespace( + get_member=lambda _user_id: None, + fetch_member=mock.AsyncMock( + side_effect=nhmisc.discord.HTTPException("Discord unavailable") + ), + ) + source_message = SimpleNamespace( + author=SimpleNamespace(id=10), + webhook_id=None, + ) + entries = (nhmisc.GateProofBatchEntry(12, 1, 1, 20, 30),) + cog = object.__new__(nhmisc.NHMisc) + + with self.assertRaises(nhmisc.discord.HTTPException): + await cog._resolve_gate_proof_batch_members( + guild, + source_message, + entries, + ) + + async def test_text_command_bootstrap_error_uses_the_active_prefix(self): + guild = SimpleNamespace(id=1) + cog = object.__new__(nhmisc.NHMisc) + cog._require_private_achievement_channel = mock.Mock() + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock(return_value=False) + ) + cog._resolve_gate_proof_message_link = mock.AsyncMock() + ctx = SimpleNamespace( + guild=guild, + clean_prefix="?", + send=mock.AsyncMock(), + ) + + with self.assertRaisesRegex( + nhmisc.commands.UserFeedbackCheckFailure, + r"\?rolesync discord", + ): + await nhmisc.NHMisc.achievement_proof( + cog, + ctx, + "https://discord.com/channels/1/20/30", + ) + + cog._resolve_gate_proof_message_link.assert_not_awaited() + + async def test_fallback_opens_the_shared_normal_review(self): + guild = SimpleNamespace(id=1) + source_message = SimpleNamespace() + fallback_view = SimpleNamespace( + source_message=source_message, + opener_id=99, + stop=mock.Mock(), + ) + normal_view = SimpleNamespace( + render_embed=mock.Mock(return_value="normal"), + message=None, + ) + interaction = SimpleNamespace( + guild=guild, + message=SimpleNamespace(), + response=SimpleNamespace(defer=mock.AsyncMock()), + edit_original_response=mock.AsyncMock(), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._build_normal_gate_proof_view = mock.AsyncMock( + return_value=normal_view + ) + + await cog._open_normal_gate_proof_fallback(interaction, fallback_view) + + interaction.response.defer.assert_awaited_once_with() + cog._build_normal_gate_proof_view.assert_awaited_once_with( + guild, + source_message, + 99, + ) + fallback_view.stop.assert_called_once_with() + interaction.edit_original_response.assert_awaited_once_with( + content=None, + embed="normal", + view=normal_view, + allowed_mentions="no-mentions", + ) + self.assertIs(normal_view.message, interaction.message) + async def test_action_requires_manage_messages_at_runtime(self): interaction = SimpleNamespace( guild=SimpleNamespace(id=1), @@ -759,7 +1076,7 @@ def render_embed(self): self.assertEqual(kwargs["view"].existing_proofs, {(10, 1): old_proof}) self.assertIs(kwargs["view"].message, response_message) - async def test_batch_rejects_gate_that_author_does_not_have(self): + async def test_failed_batch_offers_normal_gate_proof_without_writing(self): author = SimpleNamespace(id=10, display_name="Author", bot=False) guild = SimpleNamespace(id=1, get_member=lambda user_id: author) source_message = SimpleNamespace( @@ -778,6 +1095,7 @@ async def test_batch_rejects_gate_that_author_does_not_have(self): defer=mock.AsyncMock(), ), edit_original_response=mock.AsyncMock(), + original_response=mock.AsyncMock(return_value=SimpleNamespace()), ) cog = object.__new__(nhmisc.NHMisc) cog._achievement_store = SimpleNamespace( @@ -791,11 +1109,91 @@ async def test_batch_rejects_gate_that_author_does_not_have(self): ) cog._log_achievement_interaction_start = mock.Mock() - await cog._add_gate_proof_context_action(interaction, source_message) + class FakeFallbackView: + def __init__(self, cog, source_message, opener_id, error_message): + self.cog = cog + self.source_message = source_message + self.opener_id = opener_id + self.error_message = error_message + self.message = None + + package = ModuleType("_gatecount_root.nhmisc") + package.__path__ = [] + views = ModuleType("_gatecount_root.nhmisc.achievement_views") + views.GateProofBatchFallbackView = FakeFallbackView + + with mock.patch.dict( + sys.modules, + { + "_gatecount_root.nhmisc": package, + "_gatecount_root.nhmisc.achievement_views": views, + }, + ): + await cog._add_gate_proof_context_action(interaction, source_message) + + kwargs = interaction.edit_original_response.await_args.kwargs + self.assertEqual( + kwargs["content"], + "Gate proof batch failed\nGate 2 does not exist for <@10>", + ) + self.assertIsInstance(kwargs["view"], FakeFallbackView) + self.assertEqual(kwargs["view"].opener_id, 99) + self.assertEqual(kwargs["view"].source_message, source_message) + self.assertFalse(hasattr(cog._achievement_store, "attach_stargate_proofs")) - interaction.edit_original_response.assert_awaited_with( - content="Gate 2 does not exist for <@10>" + async def test_invalid_later_batch_line_offers_fallback_atomically(self): + author = SimpleNamespace(id=10, display_name="Author", bot=False) + guild = SimpleNamespace(id=1, get_member=lambda _user_id: author) + source_message = SimpleNamespace( + guild=guild, + content="\n".join( + ( + "1 https://discord.com/channels/1/50/60", + "ordinary text", + ) + ), + webhook_id=None, + author=author, + raw_mentions=(), + ) + interaction = SimpleNamespace( + guild=guild, + user=SimpleNamespace(id=99), + permissions=SimpleNamespace(manage_messages=True), + response=SimpleNamespace( + send_message=mock.AsyncMock(), + defer=mock.AsyncMock(), + ), + edit_original_response=mock.AsyncMock(), + original_response=mock.AsyncMock(return_value=SimpleNamespace()), ) + store = SimpleNamespace(is_bootstrapped=mock.AsyncMock(return_value=True)) + cog = object.__new__(nhmisc.NHMisc) + cog._achievement_store = store + cog._log_achievement_interaction_start = mock.Mock() + + class FakeFallbackView: + def __init__(self, cog, source_message, opener_id, error_message): + self.error_message = error_message + self.message = None + + package = ModuleType("_gatecount_root.nhmisc") + package.__path__ = [] + views = ModuleType("_gatecount_root.nhmisc.achievement_views") + views.GateProofBatchFallbackView = FakeFallbackView + with mock.patch.dict( + sys.modules, + { + "_gatecount_root.nhmisc": package, + "_gatecount_root.nhmisc.achievement_views": views, + }, + ): + await cog._add_gate_proof_context_action(interaction, source_message) + + kwargs = interaction.edit_original_response.await_args.kwargs + self.assertIn("line 2", kwargs["content"]) + self.assertIsInstance(kwargs["view"], FakeFallbackView) + self.assertFalse(hasattr(store, "attach_stargate_proofs")) class _CommandTree: @@ -848,6 +1246,79 @@ def test_message_action_registers_and_unregisters_with_achievement_commands(self class GateProofConfirmationTests(unittest.IsolatedAsyncioTestCase): + async def test_source_fetch_http_failure_remains_operational(self): + channel = SimpleNamespace( + fetch_message=mock.AsyncMock( + side_effect=nhmisc.discord.HTTPException("Discord unavailable") + ) + ) + guild = SimpleNamespace(get_channel=lambda _channel_id: channel) + cog = object.__new__(nhmisc.NHMisc) + cog.bot = SimpleNamespace( + get_guild=lambda _guild_id: guild, + get_channel=lambda _channel_id: None, + ) + + with self.assertRaises(nhmisc.discord.HTTPException): + await cog._fetch_gate_increment_source( + nhmisc.SourceMessageKey(1, 20, 30) + ) + + async def test_normal_confirmation_reports_source_fetch_http_failure(self): + error = nhmisc.discord.HTTPException("Discord unavailable") + interaction = SimpleNamespace( + response=SimpleNamespace(defer=mock.AsyncMock()), + ) + view = SimpleNamespace( + selected_assignments={10: 1}, + source_message=SimpleNamespace( + id=30, + channel=SimpleNamespace(id=20), + guild=SimpleNamespace(id=1), + ), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._fetch_gate_increment_source = mock.AsyncMock(side_effect=error) + cog._handle_achievement_interaction_failure = mock.AsyncMock() + + await cog._confirm_gate_proofs(interaction, view) + + cog._handle_achievement_interaction_failure.assert_awaited_once_with( + interaction, + "confirm gate proofs", + error, + public_defer=False, + ) + + async def test_batch_confirmation_reports_source_fetch_http_failure(self): + error = nhmisc.discord.HTTPException("Discord unavailable") + interaction = SimpleNamespace( + response=SimpleNamespace(defer=mock.AsyncMock()), + ) + view = SimpleNamespace( + source_message=SimpleNamespace( + id=30, + channel=SimpleNamespace(id=20), + guild=SimpleNamespace(id=1), + ), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._fetch_gate_increment_source = mock.AsyncMock(side_effect=error) + cog._handle_achievement_interaction_failure = mock.AsyncMock() + + await cog._confirm_gate_proof_batch( + interaction, + view, + replace_existing=False, + ) + + cog._handle_achievement_interaction_failure.assert_awaited_once_with( + interaction, + "confirm gate proof batch", + error, + public_defer=False, + ) + async def test_confirmation_attaches_selected_proofs_and_logs_the_mapping(self): members = { 10: SimpleNamespace(id=10, bot=False), @@ -1037,6 +1508,52 @@ async def test_batch_confirmation_attaches_each_link_to_existing_gate(self): self.assertIn("Gate 2: https://discord.com/channels/1/51/61", log_message) interaction.delete_original_response.assert_awaited_once_with() + async def test_batch_confirmation_reports_member_lookup_http_failure(self): + author = SimpleNamespace(id=10, bot=False) + guild = SimpleNamespace(id=1) + source_message = SimpleNamespace( + id=30, + channel=SimpleNamespace(id=20), + guild=guild, + content="1 https://discord.com/channels/1/50/60 <@10>", + ) + view = SimpleNamespace( + source_message=source_message, + members={10: author}, + entries=(nhmisc.GateProofBatchEntry(10, 1, 1, 50, 60),), + existing_proofs={}, + ) + interaction = SimpleNamespace( + response=SimpleNamespace(defer=mock.AsyncMock()), + edit_original_response=mock.AsyncMock(), + ) + error = nhmisc.discord.HTTPException("Discord unavailable") + cog = object.__new__(nhmisc.NHMisc) + cog._fetch_gate_increment_source = mock.AsyncMock( + return_value=source_message + ) + cog._require_private_moderation_log_channel = mock.AsyncMock() + cog._resolve_gate_proof_batch_members = mock.AsyncMock(side_effect=error) + cog._handle_achievement_interaction_failure = mock.AsyncMock() + cog._achievement_store = SimpleNamespace( + apply_stargate_proof_batch=mock.AsyncMock() + ) + + await cog._confirm_gate_proof_batch( + interaction, + view, + replace_existing=False, + ) + + interaction.response.defer.assert_awaited_once_with() + cog._handle_achievement_interaction_failure.assert_awaited_once_with( + interaction, + "confirm gate proof batch", + error, + public_defer=False, + ) + cog._achievement_store.apply_stargate_proof_batch.assert_not_awaited() + async def test_batch_confirmation_adds_only_missing_proofs_when_requested(self): author = SimpleNamespace(id=10, bot=False) guild = SimpleNamespace(id=1, get_member=lambda _user_id: author) diff --git a/tests/test_nhmisc_configuration_status.py b/tests/test_nhmisc_configuration_status.py index 0ea937b..7d3dd9c 100644 --- a/tests/test_nhmisc_configuration_status.py +++ b/tests/test_nhmisc_configuration_status.py @@ -1,4 +1,5 @@ import asyncio +import inspect import types import unittest from unittest import mock @@ -57,12 +58,20 @@ def permissions_for(self, _target): return types.SimpleNamespace(view_channel=self.public) -def command_metadata(qualified_name, signature="", *, hidden=False, children=()): +def command_metadata( + qualified_name, + signature="", + *, + hidden=False, + children=(), + short_doc="", +): return types.SimpleNamespace( qualified_name=qualified_name, signature=signature, hidden=hidden, commands=children, + short_doc=short_doc, ) @@ -635,13 +644,17 @@ async def test_nhmisc_root_shows_command_dashboard_instead_of_generic_help(self) self.assertNotIn("!nhmisc channel", commands) self.ctx.send_help.assert_not_awaited() - async def test_achievement_group_shows_revoke_command_instead_of_generic_help(self): + async def test_achievement_group_explains_every_proof_and_profile_entry_point(self): nested = command_metadata( "achievement revoke confirm", "", ) self.ctx.command = types.SimpleNamespace( commands=( + command_metadata( + "achievement proof", + "", + ), command_metadata( "achievement revoke", children=(nested,), @@ -655,8 +668,80 @@ async def test_achievement_group_shows_revoke_command_instead_of_generic_help(se embed = self.ctx.send.await_args.kwargs["embed"] fields = {field.name: field.value for field in embed.fields} self.assertEqual(embed.title, "Achievements") + self.assertIn("/achievements", embed.description) + self.assertIn("Apps → View achievements", embed.description) + self.assertIn("Apps → Grant achievements", embed.description) + self.assertIn("Apps → Increment Gate roles", embed.description) + self.assertIn("Apps → Add Gate Proof", embed.description) + self.assertIn("/gaterevoke", embed.description) + self.assertIn("Apps → Revoke Gate", embed.description) + self.assertIn("!achievement proof ", embed.description) + self.assertIn("!achievement proof ", fields["Commands"]) self.assertIn("!achievement revoke", fields["Commands"]) self.assertNotIn("confirm", fields["Commands"]) + self.assertEqual( + self.ctx.send.await_args.kwargs["allowed_mentions"], + nhmisc.discord.AllowedMentions.none(), + ) + self.ctx.send_help.assert_not_awaited() + + def test_achievement_proof_command_keeps_its_permission_and_link_contract(self): + callback = nhmisc.NHMisc.achievement_proof.callback + parameters = inspect.signature(callback).parameters + + self.assertIn("message_link", parameters) + self.assertEqual(parameters["message_link"].annotation, "str") + self.assertEqual(callback.required_permissions, {"manage_messages": True}) + + async def test_achievement_role_group_lists_its_direct_commands(self): + self.ctx.command = types.SimpleNamespace( + commands=( + command_metadata( + "achievement role bind", + "", + short_doc="Bind an existing Discord role to an achievement", + ), + command_metadata( + "achievement role unbind", + "", + short_doc="Stop tracking an achievement role", + ), + command_metadata( + "achievement role replace", + " ", + short_doc="Replace an achievement role binding", + ), + command_metadata( + "achievement role list", + short_doc="List active achievement role bindings", + ), + ) + ) + + await nhmisc.NHMisc.achievement_role.callback(self.cog, self.ctx) + + self.ctx.send.assert_awaited_once() + embed = self.ctx.send.await_args.kwargs["embed"] + self.assertEqual(embed.title, "Achievement roles") + self.assertIn("Discord role bindings", embed.description) + commands = embed.fields[0].value + self.assertEqual( + commands.splitlines(), + [ + "`!achievement role bind `", + "Bind an existing Discord role to an achievement", + "`!achievement role unbind `", + "Stop tracking an achievement role", + "`!achievement role replace `", + "Replace an achievement role binding", + "`!achievement role list`", + "List active achievement role bindings", + ], + ) + self.assertEqual( + self.ctx.send.await_args.kwargs["allowed_mentions"], + nhmisc.discord.AllowedMentions.none(), + ) self.ctx.send_help.assert_not_awaited()