2026-05-14 20:14:33 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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:
|
2026-05-14 20:30:06 +00:00
|
|
|
|
[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
|
2026-05-14 20:14:33 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
import discord
|
2026-05-14 20:30:06 +00:00
|
|
|
|
from redbot.core import Config, app_commands, commands
|
2026-05-14 20:14:33 +00:00
|
|
|
|
from redbot.core.bot import Red
|
|
|
|
|
|
from redbot.core.errors import CogLoadError
|
|
|
|
|
|
|
2026-05-14 20:30:06 +00:00
|
|
|
|
from .slash_bridge import user_slash_command
|
2026-05-14 20:14:33 +00:00
|
|
|
|
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
|
2026-05-14 20:30:06 +00:00
|
|
|
|
self.config = Config.get_conf(self, identifier=0x55534C41534800, force_registration=True)
|
|
|
|
|
|
self.config.register_global(
|
|
|
|
|
|
whitelist_enabled=False,
|
|
|
|
|
|
whitelisted_users=[],
|
2026-05-14 20:48:37 +00:00
|
|
|
|
description="",
|
2026-05-14 20:30:06 +00:00
|
|
|
|
)
|
2026-05-14 20:14:33 +00:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Lifecycle
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def cog_load(self) -> None:
|
2026-05-14 20:30:06 +00:00
|
|
|
|
# Expose config to the slash bridge module for whitelist checks
|
|
|
|
|
|
user_slash_command.extras["_userslash_config"] = self.config
|
2026-05-14 20:14:33 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-22 21:15:16 +00:00
|
|
|
|
# Clean up context variable to prevent leaks
|
|
|
|
|
|
from .utils import contexts
|
|
|
|
|
|
try:
|
|
|
|
|
|
contexts.set(None) # type: ignore
|
|
|
|
|
|
except (LookupError, TypeError):
|
|
|
|
|
|
pass # Already empty
|
|
|
|
|
|
|
2026-05-14 20:14:33 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-14 20:48:37 +00:00
|
|
|
|
# Apply custom description if set
|
|
|
|
|
|
custom_desc = await self.config.description()
|
|
|
|
|
|
if custom_desc:
|
|
|
|
|
|
user_slash_command.description = custom_desc
|
|
|
|
|
|
|
2026-05-14 20:14:33 +00:00
|
|
|
|
# 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))
|
|
|
|
|
|
|
2026-05-14 20:48:37 +00:00
|
|
|
|
@_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."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 20:30:06 +00:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 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.")
|
|
|
|
|
|
|
2026-05-14 20:14:33 +00:00
|
|
|
|
|
|
|
|
|
|
async def setup(bot: Red) -> None:
|
|
|
|
|
|
await bot.add_cog(UserSlash(bot))
|