fix: rename commands.py to slash_bridge.py to stop import shadowing

The file userslash/commands.py shadowed redbot.core.commands when Python
registered the submodule on the parent package, causing:

    AttributeError: module 'userslash.commands' has no attribute 'Cog'

Renaming the file to slash_bridge.py avoids the collision entirely.

Also adds a user-install whitelist feature:
- [p]userslash whitelist enable/disable
- [p]userslash whitelist add/remove @user
- [p]userslash whitelist list
- [p]userslash whitelist clear

When enabled, only whitelisted users may use the bot via user-install
(DMs, group DMs, non-member guilds).  Guild-installed usage is unaffected.
This commit is contained in:
Viktor 2026-05-14 20:30:06 +00:00
parent 5c7046ff7c
commit d20f988a93
2 changed files with 160 additions and 6 deletions

View file

@ -8,9 +8,10 @@ automatically patched with ``integration_types: [0, 1]`` and
``contexts: [0, 1, 2]`` so the bot works as a user-installable app. ``contexts: [0, 1, 2]`` so the bot works as a user-installable app.
Owner commands: Owner commands:
[p]userslash sync — Sync the command tree + patch user-install flags [p]userslash sync — Sync the command tree + patch user-install flags
[p]userslash patch — Patch flags on already-synced commands [p]userslash patch — Patch flags on already-synced commands
[p]userslash status — Show per-command user-install status [p]userslash status — Show per-command user-install status
[p]userslash whitelist — Manage the user-install whitelist
""" """
import asyncio import asyncio
@ -18,11 +19,11 @@ import logging
from typing import Optional from typing import Optional
import discord import discord
from redbot.core import app_commands, commands from redbot.core import Config, app_commands, commands
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.errors import CogLoadError from redbot.core.errors import CogLoadError
from .commands import user_slash_command from .slash_bridge import user_slash_command
from .context import InterContext from .context import InterContext
from .patcher import apply_user_install_native, patch_all_commands from .patcher import apply_user_install_native, patch_all_commands
from .utils import valid_app_name from .utils import valid_app_name
@ -40,12 +41,19 @@ class UserSlash(commands.Cog):
def __init__(self, bot: Red): def __init__(self, bot: Red):
self.bot = bot self.bot = bot
self._slash_registered = False self._slash_registered = False
self.config = Config.get_conf(self, identifier=0x55534C41534800, force_registration=True)
self.config.register_global(
whitelist_enabled=False,
whitelisted_users=[],
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Lifecycle # Lifecycle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def cog_load(self) -> None: async def cog_load(self) -> None:
# Expose config to the slash bridge module for whitelist checks
user_slash_command.extras["_userslash_config"] = self.config
self.bot.before_invoke(self._before_hook) self.bot.before_invoke(self._before_hook)
self.bot.add_dev_env_value( self.bot.add_dev_env_value(
"interaction", lambda ctx: getattr(ctx, "interaction", None) "interaction", lambda ctx: getattr(ctx, "interaction", None)
@ -235,6 +243,108 @@ class UserSlash(commands.Cog):
if page: if page:
await ctx.send("\n".join(page)) await ctx.send("\n".join(page))
# ------------------------------------------------------------------
# Whitelist commands
# ------------------------------------------------------------------
@_userslash.group(name="whitelist", aliases=["wl"])
async def _whitelist(self, ctx: commands.Context) -> None:
"""Manage the user-install whitelist.
When enabled, only whitelisted users can use the bot via user-install
(DMs, group DMs, and non-member guilds). Guild-installed usage is
unaffected.
"""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@_whitelist.command(name="enable")
async def _wl_enable(self, ctx: commands.Context) -> None:
"""Enable the user-install whitelist."""
await self.config.whitelist_enabled.set(True)
await ctx.send("✅ User-install whitelist is now *enabled*. Only whitelisted users can use the bot outside of guilds.")
@_whitelist.command(name="disable")
async def _wl_disable(self, ctx: commands.Context) -> None:
"""Disable the user-install whitelist (anyone can use user-install)."""
await self.config.whitelist_enabled.set(False)
await ctx.send("✅ User-install whitelist is now *disabled*. Anyone can use the bot via user-install.")
@_whitelist.command(name="add")
async def _wl_add(self, ctx: commands.Context, *users: discord.User) -> None:
"""Add one or more users to the whitelist.
Example: `[p]userslash whitelist add @User1 @User2`
"""
if not users:
await ctx.send("❌ Provide at least one user to add.")
return
async with self.config.whitelisted_users() as wl:
added = []
for user in users:
if user.id not in wl:
wl.append(user.id)
added.append(str(user))
if added:
await ctx.send(f"✅ Added to whitelist: {', '.join(added)}")
else:
await ctx.send("ℹ️ All specified users are already whitelisted.")
@_whitelist.command(name="remove", aliases=["rm"])
async def _wl_remove(self, ctx: commands.Context, *users: discord.User) -> None:
"""Remove one or more users from the whitelist.
Example: `[p]userslash whitelist remove @User1`
"""
if not users:
await ctx.send("❌ Provide at least one user to remove.")
return
async with self.config.whitelisted_users() as wl:
removed = []
for user in users:
if user.id in wl:
wl.remove(user.id)
removed.append(str(user))
if removed:
await ctx.send(f"✅ Removed from whitelist: {', '.join(removed)}")
else:
await ctx.send("ℹ️ None of the specified users were on the whitelist.")
@_whitelist.command(name="list", aliases=["ls"])
async def _wl_list(self, ctx: commands.Context) -> None:
"""Show the current whitelist and its status."""
enabled = await self.config.whitelist_enabled()
wl = await self.config.whitelisted_users()
status = "🟢 Enabled" if enabled else "🔴 Disabled"
if not wl:
await ctx.send(f"**Whitelist status:** {status}\n\nThe whitelist is empty.")
return
lines = []
for uid in wl:
user = self.bot.get_user(uid)
lines.append(f"• {user} (`{uid}`)" if user else f"• Unknown user (`{uid}`)")
header = f"**Whitelist status:** {status}\n**{len(wl)}** user(s):\n"
page = [header]
length = len(header)
for line in lines:
if length + len(line) + 1 > 1900:
await ctx.send("\n".join(page))
page = []
length = 0
page.append(line)
length += len(line) + 1
if page:
await ctx.send("\n".join(page))
@_whitelist.command(name="clear")
async def _wl_clear(self, ctx: commands.Context) -> None:
"""Remove all users from the whitelist."""
await self.config.whitelisted_users.set([])
await ctx.send("✅ Whitelist has been cleared.")
async def setup(bot: Red) -> None: async def setup(bot: Red) -> None:
await bot.add_cog(UserSlash(bot)) await bot.add_cog(UserSlash(bot))

View file

@ -4,6 +4,9 @@ The proxy slash command and its autocomplete / error handler.
This is the heart of UserSlash: a single ``/botname`` command that bridges This is the heart of UserSlash: a single ``/botname`` command that bridges
*every* Red text command through Discord's slash-command interface, with *every* Red text command through Discord's slash-command interface, with
full support for DM / group-DM / non-member-guild contexts. full support for DM / group-DM / non-member-guild contexts.
Renamed from ``commands.py`` to ``slash_bridge.py`` to avoid shadowing
``redbot.core.commands`` inside the package.
""" """
import asyncio import asyncio
@ -24,7 +27,34 @@ from redbot.core.i18n import set_contextual_locale
from .context import InterContext from .context import InterContext
from .utils import walk_aliases from .utils import walk_aliases
LOG = logging.getLogger("red.evecogs.userslash.commands") LOG = logging.getLogger("red.evecogs.userslash.slash_bridge")
# ------------------------------------------------------------------
# Whitelist helper
# ------------------------------------------------------------------
async def _check_whitelist(interaction: discord.Interaction) -> bool:
"""Return True if the user is allowed, False if blocked by the whitelist.
The whitelist only applies to *user-install* contexts (no guild, or a guild
the bot isn't a member of). Guild-installed usage is always permitted.
"""
config = getattr(
interaction.command, "extras", {}
).get("_userslash_config")
if config is None:
return True # config not wired yet — allow
# If the bot is a full member of this guild, skip the whitelist
if interaction.guild and interaction.guild.me:
return True
if not await config.whitelist_enabled():
return True
whitelist: list = await config.whitelisted_users()
return interaction.user.id in whitelist
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -53,6 +83,16 @@ async def user_slash_command(
assert isinstance(interaction.client, Red) assert isinstance(interaction.client, Red)
set_contextual_locale(str(interaction.guild_locale or interaction.locale)) set_contextual_locale(str(interaction.guild_locale or interaction.locale))
# --- Whitelist gate ---
if not await _check_whitelist(interaction):
if not interaction.response.is_done():
await interaction.response.send_message(
"❌ You are not authorised to use this bot via user-install. "
"Ask a bot owner to add you with `[p]userslash whitelist add`.",
ephemeral=True,
)
return
actual = interaction.client.get_command(command) actual = interaction.client.get_command(command)
ctx = await InterContext.from_interaction(interaction, recreate_message=True) ctx = await InterContext.from_interaction(interaction, recreate_message=True)
error = None error = None
@ -131,6 +171,10 @@ async def _autocomplete(
) -> List[app_commands.Choice[str]]: ) -> List[app_commands.Choice[str]]:
assert isinstance(interaction.client, Red) assert isinstance(interaction.client, Red)
# --- Whitelist gate ---
if not await _check_whitelist(interaction):
return []
try: try:
if not await interaction.client.allowed_by_whitelist_blacklist( if not await interaction.client.allowed_by_whitelist_blacklist(
interaction.user interaction.user