EveCogs/userslash/__init__.py
Viktor d20f988a93 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.
2026-05-14 20:30:06 +00:00

350 lines
14 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
UserSlash — User-installable slash-command bridge for Red-DiscordBot.
Replaces OneTrueSlash. Registers a single proxy slash command (named after
the bot) that makes *every* Red text command accessible via slash — in
servers, DMs, and group DMs. After syncing, all global commands are
automatically patched with ``integration_types: [0, 1]`` and
``contexts: [0, 1, 2]`` so the bot works as a user-installable app.
Owner commands:
[p]userslash sync — Sync the command tree + patch user-install flags
[p]userslash patch — Patch flags on already-synced commands
[p]userslash status — Show per-command user-install status
[p]userslash whitelist — Manage the user-install whitelist
"""
import asyncio
import logging
from typing import Optional
import discord
from redbot.core import Config, app_commands, commands
from redbot.core.bot import Red
from redbot.core.errors import CogLoadError
from .slash_bridge import user_slash_command
from .context import InterContext
from .patcher import apply_user_install_native, patch_all_commands
from .utils import valid_app_name
LOG = logging.getLogger("red.evecogs.userslash")
__red_end_user_data_statement__ = (
"This cog does not persistently store any data or metadata about users."
)
class UserSlash(commands.Cog):
"""User-installable slash commands for every Red command."""
def __init__(self, bot: Red):
self.bot = bot
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
# ------------------------------------------------------------------
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.add_dev_env_value(
"interaction", lambda ctx: getattr(ctx, "interaction", None)
)
asyncio.create_task(self._delayed_setup()) # noqa: RUF006
async def cog_unload(self) -> None:
self.bot.remove_before_invoke_hook(self._before_hook)
try:
self.bot.remove_dev_env_value("interaction")
except Exception:
pass
if self._slash_registered:
self.bot.tree.remove_command(user_slash_command.name, guild=None)
async def _delayed_setup(self) -> None:
"""Wait for the bot to be fully ready, then register the slash command."""
await self.bot.wait_until_red_ready()
assert self.bot.user
# Apply native user-install flags (discord.py 2.4+)
apply_user_install_native(user_slash_command)
try:
user_slash_command.name = valid_app_name(self.bot.user.name)
self.bot.tree.add_command(user_slash_command, guild=None)
self._slash_registered = True
LOG.info(
"Registered /%s as user-installable proxy command.",
user_slash_command.name,
)
except ValueError:
await self.bot.send_to_owners(
f"`userslash` could not convert the name {self.bot.user.name!r} "
"into a valid slash-command name. The name was left unchanged."
)
except app_commands.CommandAlreadyRegistered:
raise CogLoadError(
f"A slash command named `{user_slash_command.name}` is already "
"registered. Unload **OneTrueSlash** (or the conflicting cog) first."
) from None
except app_commands.CommandLimitReached:
raise CogLoadError(
f"{self.bot.user.name} has reached the 100-command global limit."
) from None
# ------------------------------------------------------------------
# Hooks & listeners
# ------------------------------------------------------------------
@staticmethod
async def _before_hook(ctx: commands.Context) -> None:
"""Attach the real Interaction to the context so hybrid commands work."""
interaction: Optional[discord.Interaction] = getattr(
ctx, "_interaction", None
)
if not interaction or getattr(ctx.command, "__commands_is_hybrid__", False):
return
ctx.interaction = interaction # type: ignore[attr-defined]
if not interaction.response.is_done():
ctx._deferring = True # type: ignore[attr-defined]
await interaction.response.defer(ephemeral=False)
@commands.Cog.listener()
async def on_user_update(
self, before: discord.User, after: discord.User
) -> None:
"""Update the slash-command name when the bot's username changes."""
assert self.bot.user
if after.id != self.bot.user.id or before.name == after.name:
return
old_name = user_slash_command.name
try:
user_slash_command.name = valid_app_name(after.name)
except ValueError:
await self.bot.send_to_owners(
f"`userslash` could not convert {after.name!r} into a valid "
"slash-command name. The name was left unchanged."
)
return
self.bot.tree.remove_command(old_name)
apply_user_install_native(user_slash_command)
self.bot.tree.add_command(user_slash_command, guild=None)
await self.bot.send_to_owners(
"The bot's username changed. `userslash`'s slash command has been "
"updated.\n**Run `[p]userslash sync` to push the change to Discord.**"
)
# ------------------------------------------------------------------
# Owner commands
# ------------------------------------------------------------------
@commands.is_owner()
@commands.group(name="userslash")
async def _userslash(self, ctx: commands.Context) -> None:
"""Manage UserSlash — the user-installable slash bridge."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@_userslash.command(name="sync")
async def _sync(self, ctx: commands.Context) -> None:
"""Sync the command tree, then patch every command for user-install."""
msg = await ctx.send("⏳ Syncing command tree…")
try:
synced = await self.bot.tree.sync()
except discord.HTTPException as exc:
await msg.edit(content=f"❌ Sync failed: {exc}")
return
await msg.edit(
content=f"✅ Synced **{len(synced)}** commands. Patching user-install flags…"
)
try:
patched, total = await patch_all_commands(self.bot)
await msg.edit(
content=(
f"✅ **Done!** Synced {len(synced)} commands — "
f"patched {patched}/{total} with user-install flags.\n"
"Users can now install the bot to their profile and use "
"commands in DMs & group DMs."
)
)
except discord.HTTPException as exc:
await msg.edit(
content=(
f"⚠️ Synced {len(synced)} commands but patching failed: {exc}\n"
"Try `[p]userslash patch` again in a moment."
)
)
@_userslash.command(name="patch")
async def _patch(self, ctx: commands.Context) -> None:
"""Patch already-registered commands with user-install flags (no full sync)."""
msg = await ctx.send("⏳ Patching commands…")
try:
patched, total = await patch_all_commands(self.bot)
await msg.edit(
content=f"✅ Patched **{patched}/{total}** commands with user-install flags."
)
except discord.HTTPException as exc:
await msg.edit(content=f"❌ Patching failed: {exc}")
@_userslash.command(name="status")
async def _status(self, ctx: commands.Context) -> None:
"""Show user-install status for every registered global command."""
try:
from .patcher import get_global_commands
cmds = await get_global_commands(self.bot)
except Exception as exc:
await ctx.send(f"❌ Could not fetch commands: {exc}")
return
if not cmds:
await ctx.send("No global commands are registered.")
return
lines = []
for cmd in cmds:
types = cmd.get("integration_types", [0])
ctxs = cmd.get("contexts") or [0]
user_ok = 1 in types
dm_ok = 1 in ctxs
gdm_ok = 2 in ctxs
ok = user_ok and dm_ok and gdm_ok
lines.append(
f"{'✅' if ok else '⚠️'} `/{cmd['name']}` — "
f"User: {'Yes' if user_ok else 'No'} · "
f"DM: {'Yes' if dm_ok else 'No'} · "
f"Group: {'Yes' if gdm_ok else 'No'}"
)
# Paginate if needed (2000 char limit)
page = []
length = 0
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 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:
await bot.add_cog(UserSlash(bot))