diff --git a/userslash/__init__.py b/userslash/__init__.py index 96e545d..05b0e41 100644 --- a/userslash/__init__.py +++ b/userslash/__init__.py @@ -45,6 +45,7 @@ class UserSlash(commands.Cog): self.config.register_global( whitelist_enabled=False, whitelisted_users=[], + description="", ) # ------------------------------------------------------------------ @@ -74,6 +75,11 @@ class UserSlash(commands.Cog): await self.bot.wait_until_red_ready() assert self.bot.user + # Apply custom description if set + custom_desc = await self.config.description() + if custom_desc: + user_slash_command.description = custom_desc + # Apply native user-install flags (discord.py 2.4+) apply_user_install_native(user_slash_command) @@ -243,6 +249,35 @@ class UserSlash(commands.Cog): if page: await ctx.send("\n".join(page)) + @_userslash.command(name="setdescription", aliases=["setdesc"]) + async def _setdesc(self, ctx: commands.Context, *, description: str = "") -> None: + """Set a custom description for the slash command. + + The description appears under the ``/botname`` command in the Discord UI. + Pass no argument (or an empty string) to reset to the default. + + Run ``[p]userslash sync`` afterwards to push the change to Discord. + + Example: ``[p]userslash setdescription Shine bright like a Ruby`` + """ + if not description.strip(): + await self.config.description.set("") + user_slash_command.description = ( + "Shine bright like a Ruby" + ) + await ctx.send( + "✅ Description reset to default. " + "Run `[p]userslash sync` to push the change." + ) + else: + description = description[:100] # Discord limit + await self.config.description.set(description) + user_slash_command.description = description + await ctx.send( + f"✅ Description set to: *{description}*\n" + "Run `[p]userslash sync` to push the change." + ) + # ------------------------------------------------------------------ # Whitelist commands # ------------------------------------------------------------------ diff --git a/userslash/slash_bridge.py b/userslash/slash_bridge.py index b9df276..e504f37 100644 --- a/userslash/slash_bridge.py +++ b/userslash/slash_bridge.py @@ -30,6 +30,57 @@ from .utils import walk_aliases LOG = logging.getLogger("red.evecogs.userslash.slash_bridge") +# ------------------------------------------------------------------ +# Context helpers +# ------------------------------------------------------------------ + +def _is_user_install_context(interaction: discord.Interaction) -> bool: + """True when the interaction originates outside a guild the bot is a member of.""" + return not interaction.guild or not getattr(interaction.guild, "me", None) + + +def _command_needs_guild(cmd: commands.Command) -> bool: + """Heuristic: return True if *cmd* almost certainly requires a guild context. + + Walks the parent chain so subcommands inherit their parent's requirements. + Checks: + - ``@commands.guild_only()`` / ``@commands.no_pm()`` in discord.py checks + - Red privilege levels (MOD, ADMIN, GUILD_OWNER) + - Explicit ``user_perms`` on Red's ``Requires`` object + """ + current: Optional[commands.Command] = cmd + while current is not None: + # discord.py @guild_only() / @no_pm() + for check in getattr(current, "checks", ()): + qn = getattr(check, "__qualname__", "") + if "guild_only" in qn or "no_pm" in qn: + return True + + # Red's Requires system + requires = getattr(current, "requires", None) + if requires is not None: + # MOD=1, ADMIN=2, GUILD_OWNER=3 all require a guild + priv = getattr(requires, "privilege_level", None) + if priv is not None: + try: + if priv.value in (1, 2, 3): + return True + except (AttributeError, ValueError): + pass + + # Explicit user-permission requirements (manage_guild, etc.) + user_perms = getattr(requires, "user_perms", None) + if user_perms is not None: + try: + if isinstance(user_perms, discord.Permissions) and user_perms.value != 0: + return True + except Exception: + pass + + current = getattr(current, "parent", None) + return False + + # ------------------------------------------------------------------ # Whitelist helper # ------------------------------------------------------------------ @@ -69,7 +120,7 @@ async def user_slash_command( attachment: Optional[discord.Attachment] = None, ) -> None: """ - Run any bot command from anywhere — servers, DMs, and group DMs. + Shine bright like a Ruby Parameters ----------- @@ -93,6 +144,18 @@ async def user_slash_command( ) return + # --- Block guild-only commands in user-install contexts --- + if _is_user_install_context(interaction): + probe = interaction.client.get_command(command) + if probe and _command_needs_guild(probe): + if not interaction.response.is_done(): + await interaction.response.send_message( + f"❌ `{command}` requires a server context and can't be " + "used in DMs, group DMs, or servers the bot isn't in.", + ephemeral=True, + ) + return + actual = interaction.client.get_command(command) ctx = await InterContext.from_interaction(interaction, recreate_message=True) error = None @@ -202,26 +265,47 @@ async def _autocomplete( # Fallback settings when guild config is unavailable help_settings = await HelpSettings.from_context.__wrapped__(ctx) # type: ignore + user_install = _is_user_install_context(interaction) + show_hidden = help_settings.show_hidden + + # Build the candidate pool — in user-install contexts, pre-filter to + # commands that can actually work outside a guild. + all_names = list( + walk_aliases(interaction.client, show_hidden=show_hidden) + ) + + if user_install: + pool: List[str] = [] + for n in all_names: + cmd = interaction.client.get_command(n) + if cmd and not _command_needs_guild(cmd): + pool.append(n) + else: + pool = all_names + if current: extracted = cast( List[str], await asyncio.get_event_loop().run_in_executor( None, heapq.nlargest, - 6, - walk_aliases( - interaction.client, show_hidden=help_settings.show_hidden - ), + 24 if user_install else 6, + pool, functools.partial(fuzz.token_sort_ratio, current), ), ) extracted.append("help") else: - extracted = ["help"] + if user_install: + # Show available DM-compatible commands when nothing is typed yet + extracted = pool[:24] + extracted.append("help") + else: + extracted = ["help"] _filter: Callable[[commands.Command], Awaitable[bool]] = ( operator.methodcaller( - "can_run" if help_settings.show_hidden else "can_see", ctx + "can_run" if show_hidden else "can_see", ctx ) ) matches: Dict[commands.Command, str] = {}