feat(userslash): context-aware command filtering + custom description

User-install contexts (DMs, group DMs, non-member guilds) now only show
commands that can actually work outside a guild:

- _command_needs_guild() heuristic checks @guild_only, Red privilege
  levels (MOD/ADMIN/GUILD_OWNER), and user permission requirements,
  walking the parent chain for subcommands.
- Autocomplete pre-filters the candidate pool in user-install contexts
  and shows available DM-compatible commands when nothing is typed.
- Command execution blocks guild-only commands early with a clear
  ephemeral error instead of letting them fail cryptically.

Also:
- Slash command description changed to 'Shine bright like a Ruby'
- Added [p]userslash setdescription command to change it on the fly
  (persisted in Config, applied on cog load)
This commit is contained in:
Viktor 2026-05-14 20:48:37 +00:00
parent 180121bbdc
commit f75f51a6b4
2 changed files with 126 additions and 7 deletions

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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] = {}