feat: add UserSlash cog — user-installable slash commands for every Red command
Replaces OneTrueSlash with full user-install support: - Single proxy slash command bridges all text commands - Works in servers, DMs, and group DMs - REST API patcher sets integration_types & contexts on all global commands - Owner commands: [p]userslash sync/patch/status - Fuzzy autocomplete (rapidfuzz) - Enhanced InterContext/InterMessage for non-guild contexts
This commit is contained in:
parent
004d1d61ea
commit
8858470392
9 changed files with 1147 additions and 1 deletions
|
|
@ -3,6 +3,6 @@
|
|||
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
|
||||
"name": "EveCogs",
|
||||
"short": "A collection of advanced cogs for Red-DiscordBot.",
|
||||
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), and ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export).",
|
||||
"description": "EveCogs is a premium cog repository featuring NexusCore (the ultimate all-in-one server management cog with tickets, applications, suggestions, reaction roles, giveaways, logging, moderation & economy), Wormhole v3.4.0 (cross-server relay with 85+ hybrid commands, context menus, ToS gate, reports, analytics & more), NewHelpMenu (Components V2 help overhaul), VoiceMeister (ultimate temporary voice channel system with Join-to-Create, persistent control panel, name templates, game detection & logging), SelfbotGuard (advanced selfbot detection), ServerWhitelist (owner-only server join whitelist with blacklist, lock mode, interactive browser, purge & export), and UserSlash (user-installable slash-command bridge — every Red command works in servers, DMs & group DMs).",
|
||||
"type": "REPO"
|
||||
}
|
||||
|
|
|
|||
240
userslash/__init__.py
Normal file
240
userslash/__init__.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from redbot.core import app_commands, commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.errors import CogLoadError
|
||||
|
||||
from .commands 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def cog_load(self) -> None:
|
||||
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))
|
||||
|
||||
|
||||
async def setup(bot: Red) -> None:
|
||||
await bot.add_cog(UserSlash(bot))
|
||||
50
userslash/channel.py
Normal file
50
userslash/channel.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
InterChannel — thin overlay that routes ``send`` / ``typing`` / ``permissions_for``
|
||||
through the slash-interaction context instead of the regular channel object.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Union, cast
|
||||
|
||||
import discord
|
||||
|
||||
from .utils import Thinking, contexts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.context_managers import Typing
|
||||
|
||||
Base = discord.abc.Messageable
|
||||
else:
|
||||
Base = object
|
||||
|
||||
|
||||
class InterChannel(Base):
|
||||
"""Mixed into the real channel class at runtime so existing code keeps working."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def permissions_for(
|
||||
self, obj: Union[discord.abc.User, discord.Role], /
|
||||
) -> discord.Permissions:
|
||||
try:
|
||||
ctx = contexts.get()
|
||||
except LookupError:
|
||||
pass
|
||||
else:
|
||||
interaction = ctx._interaction
|
||||
bot_user = cast(discord.ClientUser, ctx.bot.user)
|
||||
if obj.id == interaction.user.id:
|
||||
return ctx.permissions
|
||||
elif obj.id == bot_user.id:
|
||||
return ctx.bot_permissions
|
||||
return super().permissions_for(obj) # type: ignore
|
||||
|
||||
def send(self, *args, **kwargs):
|
||||
return contexts.get(super()).send(*args, **kwargs)
|
||||
|
||||
def typing(self) -> Union[Thinking, "Typing"]:
|
||||
try:
|
||||
ctx = contexts.get()
|
||||
except LookupError:
|
||||
return super().typing()
|
||||
else:
|
||||
return Thinking(ctx)
|
||||
237
userslash/commands.py
Normal file
237
userslash/commands.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""
|
||||
The proxy slash command and its autocomplete / error handler.
|
||||
|
||||
This is the heart of UserSlash: a single ``/botname`` command that bridges
|
||||
*every* Red text command through Discord's slash-command interface, with
|
||||
full support for DM / group-DM / non-member-guild contexts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import heapq
|
||||
import logging
|
||||
import operator
|
||||
from copy import copy
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, cast
|
||||
|
||||
import discord
|
||||
from rapidfuzz import fuzz
|
||||
from redbot.core import app_commands, commands
|
||||
from redbot.core.bot import Red
|
||||
from redbot.core.commands.help import HelpSettings
|
||||
from redbot.core.i18n import set_contextual_locale
|
||||
|
||||
from .context import InterContext
|
||||
from .utils import walk_aliases
|
||||
|
||||
LOG = logging.getLogger("red.evecogs.userslash.commands")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The one true (user-installable) slash command
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(extras={"red_force_enable": True})
|
||||
async def user_slash_command(
|
||||
interaction: discord.Interaction,
|
||||
command: str,
|
||||
arguments: Optional[str] = None,
|
||||
attachment: Optional[discord.Attachment] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run any bot command from anywhere — servers, DMs, and group DMs.
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
command: str
|
||||
The text-based command to run.
|
||||
arguments: Optional[str]
|
||||
The arguments to provide to the command, if any.
|
||||
attachment: Optional[Attachment]
|
||||
An attached file to provide to the command, if any.
|
||||
"""
|
||||
assert isinstance(interaction.client, Red)
|
||||
set_contextual_locale(str(interaction.guild_locale or interaction.locale))
|
||||
|
||||
actual = interaction.client.get_command(command)
|
||||
ctx = await InterContext.from_interaction(interaction, recreate_message=True)
|
||||
error = None
|
||||
|
||||
if command == "help":
|
||||
ctx._deferring = True
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
actual = None
|
||||
if arguments:
|
||||
actual = interaction.client.get_command(arguments)
|
||||
if actual and (signature := actual.signature):
|
||||
actual = copy(actual)
|
||||
actual.usage = f"arguments:{signature}"
|
||||
await interaction.client.send_help_for(
|
||||
ctx, actual or interaction.client, from_help_command=True
|
||||
)
|
||||
else:
|
||||
ferror: asyncio.Task[Tuple[InterContext, commands.CommandError]] = (
|
||||
asyncio.create_task(
|
||||
interaction.client.wait_for(
|
||||
"command_error", check=lambda c, _: c is ctx
|
||||
)
|
||||
)
|
||||
)
|
||||
ferror.add_done_callback(
|
||||
lambda _: setattr(ctx, "interaction", interaction)
|
||||
)
|
||||
await interaction.client.invoke(ctx)
|
||||
if not interaction.response.is_done():
|
||||
ctx._deferring = True
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
if ferror.done():
|
||||
error = ferror.exception() or ferror.result()[1]
|
||||
ferror.cancel()
|
||||
|
||||
# ---- Followup / cleanup ----
|
||||
if ctx._deferring and not interaction.is_expired():
|
||||
if error is None:
|
||||
if ctx._ticked:
|
||||
await interaction.followup.send(ctx._ticked, ephemeral=True)
|
||||
else:
|
||||
await interaction.delete_original_response()
|
||||
|
||||
elif isinstance(error, commands.CommandNotFound):
|
||||
await interaction.followup.send(
|
||||
f"❌ Command `{command}` was not found.", ephemeral=True
|
||||
)
|
||||
|
||||
elif isinstance(error, commands.NoPrivateMessage):
|
||||
await interaction.followup.send(
|
||||
f"❌ `{command}` can only be used inside a server.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
elif isinstance(error, commands.CheckFailure):
|
||||
if not interaction.guild:
|
||||
await interaction.followup.send(
|
||||
f"❌ `{command}` requires a server context or you lack the "
|
||||
"needed permissions.",
|
||||
ephemeral=True,
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
f"❌ You don't have permission to run `{command}`.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Autocomplete — fuzzy-matches available commands
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@user_slash_command.autocomplete("command")
|
||||
async def _autocomplete(
|
||||
interaction: discord.Interaction, current: str
|
||||
) -> List[app_commands.Choice[str]]:
|
||||
assert isinstance(interaction.client, Red)
|
||||
|
||||
try:
|
||||
if not await interaction.client.allowed_by_whitelist_blacklist(
|
||||
interaction.user
|
||||
):
|
||||
return []
|
||||
except Exception:
|
||||
pass # safety net for unusual DM contexts
|
||||
|
||||
try:
|
||||
ctx = await InterContext.from_interaction(interaction)
|
||||
eligible = await interaction.client.message_eligible_as_command(
|
||||
ctx.message
|
||||
)
|
||||
except Exception:
|
||||
eligible = True # slash interactions are always "eligible"
|
||||
ctx = await InterContext.from_interaction(interaction)
|
||||
|
||||
if not eligible:
|
||||
# Still provide basic help for slash
|
||||
return [app_commands.Choice(name="help", value="help")]
|
||||
|
||||
try:
|
||||
help_settings = await HelpSettings.from_context(ctx)
|
||||
except Exception:
|
||||
# Fallback settings when guild config is unavailable
|
||||
help_settings = await HelpSettings.from_context.__wrapped__(ctx) # type: ignore
|
||||
|
||||
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
|
||||
),
|
||||
functools.partial(fuzz.token_sort_ratio, current),
|
||||
),
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
matches: Dict[commands.Command, str] = {}
|
||||
for name in extracted:
|
||||
cmd = interaction.client.get_command(name)
|
||||
if not cmd or cmd in matches:
|
||||
continue
|
||||
try:
|
||||
if (name == "help" and await cmd.can_run(ctx)) or await _filter(cmd):
|
||||
if len(name) > 100:
|
||||
name = name[:99] + "\N{HORIZONTAL ELLIPSIS}"
|
||||
matches[cmd] = name
|
||||
except commands.CommandError:
|
||||
pass
|
||||
except Exception:
|
||||
# Swallow unexpected errors in DM/group-DM contexts so autocomplete
|
||||
# still returns *something*.
|
||||
pass
|
||||
return [app_commands.Choice(name=n, value=n) for n in matches.values()]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@user_slash_command.error
|
||||
async def _error(interaction: discord.Interaction, error: Exception):
|
||||
assert isinstance(interaction.client, Red)
|
||||
|
||||
if isinstance(error, app_commands.CommandInvokeError):
|
||||
error = error.original
|
||||
original = getattr(error, "original", error)
|
||||
|
||||
# Friendly message for guild-only commands invoked outside a guild
|
||||
if isinstance(original, (commands.NoPrivateMessage, commands.CheckFailure)):
|
||||
try:
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.send_message(
|
||||
"❌ This command cannot be used here (it may require a server).",
|
||||
ephemeral=True,
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
"❌ This command cannot be used here (it may require a server).",
|
||||
ephemeral=True,
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
# Everything else — forward to Red's built-in error handler
|
||||
ctx = await InterContext.from_interaction(
|
||||
interaction, recreate_message=True
|
||||
)
|
||||
await interaction.client.on_command_error(
|
||||
ctx, commands.CommandInvokeError(original)
|
||||
)
|
||||
215
userslash/context.py
Normal file
215
userslash/context.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""
|
||||
InterContext — a ``commands.Context`` subclass that bridges slash-command
|
||||
interactions into Red's text-command pipeline.
|
||||
|
||||
Enhanced from OneTrueSlash with robust handling for user-install contexts
|
||||
where ``guild`` is ``None`` (DMs, group DMs, non-member guilds).
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import types
|
||||
from copy import copy
|
||||
from typing import Optional, Type, Union
|
||||
|
||||
import discord
|
||||
from discord.ext.commands.view import StringView
|
||||
from redbot.core import commands
|
||||
from redbot.core.bot import Red
|
||||
|
||||
from .message import InterMessage
|
||||
from .utils import Thinking, contexts
|
||||
|
||||
# Parameters that ``discord.abc.Messageable.send`` accepts but
|
||||
# ``discord.Webhook.send`` does not — we silently discard them.
|
||||
_INCOMPATIBLE_SEND_PARAMS = tuple(
|
||||
k
|
||||
for k in inspect.signature(discord.abc.Messageable.send).parameters
|
||||
if k not in inspect.signature(discord.Webhook.send).parameters
|
||||
)
|
||||
|
||||
|
||||
class InterContext(commands.Context):
|
||||
"""Drop-in ``commands.Context`` replacement for slash interactions."""
|
||||
|
||||
_deferring: bool = False
|
||||
_ticked: Optional[str] = None
|
||||
_interaction: discord.Interaction[Red]
|
||||
message: InterMessage
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _get_type(cls, bot: Red) -> Type["InterContext"]:
|
||||
default = bot.get_context.__kwdefaults__.get("cls", None)
|
||||
if not isinstance(default, type) or default in cls.__mro__:
|
||||
return cls
|
||||
try:
|
||||
return types.new_class(cls.__name__, (cls, default))
|
||||
except Exception:
|
||||
return cls
|
||||
|
||||
@classmethod
|
||||
async def from_interaction(
|
||||
cls,
|
||||
interaction: discord.Interaction[Red],
|
||||
*,
|
||||
recreate_message: bool = False,
|
||||
) -> "InterContext":
|
||||
prefix = f"</{interaction.data['name']}:{interaction.data['id']}> command:"
|
||||
|
||||
# Reuse existing context if present on the ContextVar
|
||||
try:
|
||||
self = contexts.get()
|
||||
if recreate_message:
|
||||
assert self.prefix is not None
|
||||
self.message._recreate_from_interaction(interaction, prefix)
|
||||
view = self.view = StringView(self.message.content)
|
||||
view.skip_string(self.prefix)
|
||||
invoker = view.get_word()
|
||||
self.invoked_with = invoker
|
||||
self.command = interaction.client.all_commands.get(invoker)
|
||||
return self
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
# Build a fresh context
|
||||
message = InterMessage._from_interaction(interaction, prefix)
|
||||
view = StringView(message.content)
|
||||
view.skip_string(prefix)
|
||||
invoker = view.get_word()
|
||||
|
||||
self = cls._get_type(interaction.client)(
|
||||
message=message,
|
||||
prefix=prefix,
|
||||
bot=interaction.client,
|
||||
view=view,
|
||||
invoked_with=invoker,
|
||||
command=interaction.client.all_commands.get(invoker),
|
||||
)
|
||||
# Keep _interaction private so d.py parses args the old way
|
||||
self._interaction = interaction
|
||||
interaction._baton = self # type: ignore[attr-defined]
|
||||
contexts.set(self)
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def clean_prefix(self) -> str:
|
||||
return f"/{self._interaction.data['name']} command:"
|
||||
|
||||
async def tick(self, *, message: Optional[str] = None) -> bool:
|
||||
return await super().tick(message="Done." if message is None else message)
|
||||
|
||||
async def react_quietly(
|
||||
self,
|
||||
reaction: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str],
|
||||
*,
|
||||
message: Optional[str] = None,
|
||||
) -> bool:
|
||||
self._ticked = f"{reaction} {message}" if message else str(reaction)
|
||||
return False
|
||||
|
||||
async def send(self, *args, **kwargs):
|
||||
interaction = self._interaction
|
||||
if interaction.is_expired():
|
||||
assert interaction.channel
|
||||
return await interaction.channel.send(*args, **kwargs) # type: ignore
|
||||
await self.typing(ephemeral=True)
|
||||
self._deferring = False
|
||||
delete_after = kwargs.pop("delete_after", None)
|
||||
for key in _INCOMPATIBLE_SEND_PARAMS:
|
||||
kwargs.pop(key, None)
|
||||
m = await interaction.followup.send(*args, **kwargs)
|
||||
if delete_after:
|
||||
await m.delete(delay=delete_after)
|
||||
return m
|
||||
|
||||
def typing(self, *, ephemeral: bool = False) -> Thinking:
|
||||
return Thinking(self, ephemeral=ephemeral)
|
||||
|
||||
async def defer(self, *, ephemeral: bool = False) -> None:
|
||||
await self._interaction.response.defer(ephemeral=ephemeral)
|
||||
|
||||
async def send_help(
|
||||
self,
|
||||
command: Optional[Union[commands.Command, commands.GroupMixin, str]] = None,
|
||||
):
|
||||
command = command or self.command
|
||||
if isinstance(command, str):
|
||||
command = self.bot.get_command(command) or command
|
||||
signature: str
|
||||
if signature := getattr(command, "signature", ""):
|
||||
assert not isinstance(command, str)
|
||||
command = copy(command)
|
||||
command.usage = f"arguments:{signature}"
|
||||
return await super().send_help(command)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Permissions (DM-safe)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_implicit_permissions(
|
||||
self, user: discord.abc.User, base: discord.Permissions
|
||||
) -> discord.Permissions:
|
||||
if base.administrator or (
|
||||
self.guild and self.guild.owner_id == user.id
|
||||
):
|
||||
return discord.Permissions.all()
|
||||
|
||||
base = copy(base)
|
||||
if not base.send_messages:
|
||||
base.send_tts_messages = False
|
||||
base.mention_everyone = False
|
||||
base.embed_links = False
|
||||
base.attach_files = False
|
||||
|
||||
if not base.read_messages:
|
||||
base &= ~discord.Permissions.all_channel()
|
||||
|
||||
channel_type = getattr(self.channel, "type", None)
|
||||
if channel_type in (
|
||||
discord.ChannelType.voice,
|
||||
discord.ChannelType.stage_voice,
|
||||
):
|
||||
if not base.connect:
|
||||
denied = discord.Permissions.voice()
|
||||
denied.update(manage_channels=True, manage_roles=True)
|
||||
base &= ~denied
|
||||
elif channel_type not in (
|
||||
discord.ChannelType.private,
|
||||
discord.ChannelType.group,
|
||||
None,
|
||||
):
|
||||
# Text channel in a guild — strip voice perms
|
||||
base &= ~discord.Permissions.voice()
|
||||
|
||||
return base
|
||||
|
||||
@discord.utils.cached_property
|
||||
def permissions(self) -> discord.Permissions:
|
||||
if (
|
||||
self._interaction._permissions == 0
|
||||
or not self.guild
|
||||
):
|
||||
return discord.Permissions._dm_permissions() # type: ignore
|
||||
return self._apply_implicit_permissions(
|
||||
self.author, self._interaction.permissions
|
||||
)
|
||||
|
||||
@discord.utils.cached_property
|
||||
def bot_permissions(self) -> discord.Permissions:
|
||||
if not self.guild:
|
||||
# In DMs the bot can always send, embed, and attach
|
||||
return discord.Permissions._dm_permissions() | discord.Permissions( # type: ignore
|
||||
send_messages=True, attach_files=True, embed_links=True
|
||||
)
|
||||
return self._apply_implicit_permissions(
|
||||
self.me, self._interaction.app_permissions
|
||||
) | discord.Permissions(
|
||||
send_messages=True, attach_files=True, embed_links=True
|
||||
)
|
||||
22
userslash/info.json
Normal file
22
userslash/info.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"author": ["everestmcarthur"],
|
||||
"install_msg": "**UserSlash** loaded!\n\n**Setup (one-time):**\n1. Go to the **Discord Developer Portal** → Your Application → **Installation**\n2. Under *Installation Contexts*, enable **User Install**\n3. Under *Default Install Settings — User Install*, add the `applications.commands` scope\n4. Run `[p]userslash sync` to register & patch commands\n5. Share the OAuth2 User-Install link so users can add your bot to their profile!\n\n⚠️ **Unload OneTrueSlash** first if you have it loaded — this cog replaces it.\n\nAll Red text commands are now accessible as slash commands in servers, DMs, and group DMs.",
|
||||
"name": "UserSlash",
|
||||
"disabled": false,
|
||||
"short": "User-installable slash commands — every Red command works in servers, DMs, and group DMs.",
|
||||
"description": "UserSlash replaces OneTrueSlash with full user-install support. It registers a single proxy slash command (named after the bot) that bridges all Red text commands, then patches every global command with user-install integration types. Commands work everywhere: guild channels, bot DMs, group DMs, and even servers where the bot isn't a member. Includes owner commands for syncing, patching, and checking status. Gracefully handles non-guild contexts with helpful error messages when a command requires a server.",
|
||||
"tags": [
|
||||
"slash",
|
||||
"user-install",
|
||||
"commands",
|
||||
"dm",
|
||||
"group-dm",
|
||||
"hybrid",
|
||||
"utility"
|
||||
],
|
||||
"type": "COG",
|
||||
"end_user_data_statement": "This cog does not persistently store any data or metadata about users.",
|
||||
"min_bot_version": "3.5.0",
|
||||
"min_python_version": [3, 9, 0],
|
||||
"requirements": ["rapidfuzz"]
|
||||
}
|
||||
195
userslash/message.py
Normal file
195
userslash/message.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
InterMessage — lightweight ``discord.Message`` stand-in built from
|
||||
a slash-command ``Interaction`` so Red's text-command pipeline can process it.
|
||||
|
||||
Enhanced from OneTrueSlash to handle user-install contexts (DMs, group DMs,
|
||||
and guilds where the bot is not a member).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from copy import copy
|
||||
from typing import TypeVar
|
||||
|
||||
import discord
|
||||
|
||||
from .channel import InterChannel
|
||||
|
||||
_TT = TypeVar("_TT", bound=type)
|
||||
|
||||
|
||||
def _noop_step(*_args, **_kwargs):
|
||||
"""Coroutine that just yields to the event loop once."""
|
||||
return asyncio.sleep(0)
|
||||
|
||||
|
||||
def neuter_coros(cls: _TT) -> _TT:
|
||||
"""Replace every inherited coroutine with a harmless no-op."""
|
||||
for name in dir(cls):
|
||||
if name in cls.__dict__:
|
||||
continue
|
||||
if (attr := getattr(cls, name, None)) is None:
|
||||
continue
|
||||
if asyncio.iscoroutinefunction(attr):
|
||||
setattr(cls, name, property(lambda self: _noop_step))
|
||||
return cls
|
||||
|
||||
|
||||
@neuter_coros
|
||||
class InterMessage(discord.Message):
|
||||
__slots__ = ()
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
raise RuntimeError("InterMessage must be created via _from_interaction")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _from_interaction(
|
||||
cls, interaction: discord.Interaction, prefix: str
|
||||
) -> "InterMessage":
|
||||
assert interaction.data
|
||||
assert interaction.client.user
|
||||
|
||||
self = cls.__new__(cls)
|
||||
self._state = interaction._state
|
||||
self._edited_timestamp = None
|
||||
|
||||
# Constant / default fields
|
||||
self.tts = False
|
||||
self.webhook_id = None
|
||||
self.mention_everyone = False
|
||||
self.embeds = []
|
||||
self.role_mentions = []
|
||||
self.id = interaction.id
|
||||
self.nonce = None
|
||||
self.pinned = False
|
||||
self.type = discord.MessageType.default
|
||||
self.flags = discord.MessageFlags()
|
||||
self.reactions = []
|
||||
self.reference = None
|
||||
self.application = None
|
||||
self.activity = None
|
||||
self.stickers = []
|
||||
self.components = []
|
||||
self.role_subscription = None
|
||||
self.application_id = None
|
||||
self.position = None
|
||||
|
||||
channel = interaction.channel
|
||||
self.guild = interaction.guild
|
||||
|
||||
# --- Determine author & channel based on context ---
|
||||
|
||||
if interaction.guild_id and not interaction.guild:
|
||||
# Guild ID present but guild not in cache (e.g. user-install in a
|
||||
# server the bot hasn't joined). Treat as DM so Red doesn't crash.
|
||||
if isinstance(interaction.user, discord.Member):
|
||||
self.author = interaction.user._user
|
||||
else:
|
||||
self.author = interaction.user
|
||||
channel = cls._make_dm_channel(interaction)
|
||||
|
||||
elif not interaction.guild:
|
||||
# True DM or group DM from a user-install context.
|
||||
self.author = interaction.user
|
||||
if channel is None:
|
||||
channel = cls._make_dm_channel(interaction)
|
||||
else:
|
||||
channel = copy(channel)
|
||||
|
||||
else:
|
||||
# Normal guild context — bot is a member.
|
||||
self.author = interaction.user
|
||||
if channel is not None:
|
||||
channel = copy(channel)
|
||||
else:
|
||||
channel = cls._make_dm_channel(interaction)
|
||||
|
||||
# Mix InterChannel into the channel so send/typing route correctly
|
||||
if channel is not None:
|
||||
channel.__class__ = type(
|
||||
InterChannel.__name__,
|
||||
(InterChannel, channel.__class__),
|
||||
{"__slots__": ()},
|
||||
)
|
||||
self.channel = channel # type: ignore
|
||||
|
||||
self._recreate_from_interaction(interaction, prefix)
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_dm_channel(interaction: discord.Interaction) -> discord.DMChannel:
|
||||
"""Fabricate a minimal DMChannel when the real one is unavailable."""
|
||||
assert interaction.client.user
|
||||
user = interaction.user
|
||||
user_json = (
|
||||
user._to_minimal_user_json()
|
||||
if hasattr(user, "_to_minimal_user_json")
|
||||
else {
|
||||
"id": str(user.id),
|
||||
"username": str(user),
|
||||
"discriminator": "0",
|
||||
"avatar": None,
|
||||
}
|
||||
)
|
||||
return discord.DMChannel(
|
||||
me=interaction.client.user,
|
||||
state=interaction._state,
|
||||
data={
|
||||
"id": interaction.channel_id or interaction.id,
|
||||
"name": f"DM with {user}",
|
||||
"type": 1,
|
||||
"last_message_id": None,
|
||||
"recipients": [
|
||||
user_json,
|
||||
interaction.client.user._to_minimal_user_json(),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _recreate_from_interaction(
|
||||
self, interaction: discord.Interaction, prefix: str
|
||||
) -> None:
|
||||
assert interaction.data and interaction.client.user
|
||||
|
||||
self.content = f"{prefix}{interaction.namespace.command}"
|
||||
if interaction.namespace.arguments:
|
||||
self.content = f"{self.content} {interaction.namespace.arguments}"
|
||||
if interaction.namespace.attachment:
|
||||
self.attachments = [interaction.namespace.attachment]
|
||||
else:
|
||||
self.attachments = []
|
||||
|
||||
resolved = interaction.data.get("resolved", {})
|
||||
if self.guild:
|
||||
self.mentions = [
|
||||
discord.Member(data=d, guild=self.guild, state=self._state)
|
||||
for d in resolved.get("members", {}).values()
|
||||
]
|
||||
else:
|
||||
self.mentions = [
|
||||
discord.User(data=d, state=self._state)
|
||||
for d in resolved.get("users", {}).values()
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_reference(self, *, fail_if_not_exists: bool = True):
|
||||
return None
|
||||
|
||||
def to_message_reference_dict(self):
|
||||
return discord.utils.MISSING
|
||||
|
||||
async def reply(self, *args, **kwargs):
|
||||
return await self.channel.send(*args, **kwargs)
|
||||
|
||||
def edit(self, *args, **kwargs):
|
||||
return asyncio.sleep(0, self)
|
||||
111
userslash/patcher.py
Normal file
111
userslash/patcher.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""
|
||||
REST-API patcher — ensures every global application command carries
|
||||
``integration_types: [0, 1]`` and ``contexts: [0, 1, 2]`` so the bot
|
||||
is user-installable and works in guilds, DMs, and group DMs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import discord
|
||||
from redbot.core.bot import Red
|
||||
|
||||
LOG = logging.getLogger("red.evecogs.userslash.patcher")
|
||||
|
||||
# 0 = GUILD_INSTALL, 1 = USER_INSTALL
|
||||
INTEGRATION_TYPES: List[int] = [0, 1]
|
||||
# 0 = GUILD, 1 = BOT_DM, 2 = PRIVATE_CHANNEL (group DMs)
|
||||
CONTEXTS: List[int] = [0, 1, 2]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Native discord.py 2.4+ helpers (best-effort)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def apply_user_install_native(command: Any) -> bool:
|
||||
"""
|
||||
Try to set ``allowed_installs`` / ``allowed_contexts`` using the
|
||||
native discord.py 2.4+ API. Returns ``True`` on success.
|
||||
"""
|
||||
try:
|
||||
from discord.app_commands import AppInstallationType, AppCommandContext
|
||||
|
||||
command.allowed_installs = AppInstallationType(guild=True, user=True)
|
||||
command.allowed_contexts = AppCommandContext(
|
||||
guild=True, dm_channel=True, private_channel=True
|
||||
)
|
||||
return True
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# REST fallback — works with any discord.py version
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_global_commands(bot: Red) -> List[Dict[str, Any]]:
|
||||
"""Fetch every registered global command from the Discord API."""
|
||||
app_id = bot.application_id
|
||||
if not app_id:
|
||||
raise RuntimeError("Bot application_id is not available yet.")
|
||||
route = discord.http.Route(
|
||||
"GET",
|
||||
"/applications/{application_id}/commands",
|
||||
application_id=app_id,
|
||||
)
|
||||
return await bot.http.request(route)
|
||||
|
||||
|
||||
async def patch_command(
|
||||
bot: Red,
|
||||
command_id: int | str,
|
||||
*,
|
||||
integration_types: List[int] = INTEGRATION_TYPES,
|
||||
cmd_contexts: List[int] = CONTEXTS,
|
||||
) -> Dict[str, Any]:
|
||||
"""PATCH a single global command with user-install flags."""
|
||||
route = discord.http.Route(
|
||||
"PATCH",
|
||||
"/applications/{application_id}/commands/{command_id}",
|
||||
application_id=bot.application_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
return await bot.http.request(
|
||||
route,
|
||||
json={
|
||||
"integration_types": integration_types,
|
||||
"contexts": cmd_contexts,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def patch_all_commands(bot: Red) -> Tuple[int, int]:
|
||||
"""
|
||||
Patch **all** global commands that are missing user-install flags.
|
||||
|
||||
Returns ``(patched_count, total_count)``.
|
||||
"""
|
||||
commands = await get_global_commands(bot)
|
||||
target_types = set(INTEGRATION_TYPES)
|
||||
target_contexts = set(CONTEXTS)
|
||||
|
||||
patched = 0
|
||||
for cmd in commands:
|
||||
current_types = set(cmd.get("integration_types", [0]))
|
||||
current_contexts = set(cmd.get("contexts") or [0])
|
||||
|
||||
if current_types == target_types and current_contexts == target_contexts:
|
||||
continue # already good
|
||||
|
||||
try:
|
||||
await patch_command(bot, cmd["id"])
|
||||
patched += 1
|
||||
LOG.info(
|
||||
"Patched command /%s (%s) with user-install flags.",
|
||||
cmd["name"],
|
||||
cmd["id"],
|
||||
)
|
||||
except discord.HTTPException as exc:
|
||||
LOG.warning("Failed to patch /%s: %s", cmd["name"], exc)
|
||||
|
||||
return patched, len(commands)
|
||||
76
userslash/utils.py
Normal file
76
userslash/utils.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""
|
||||
Shared utilities for UserSlash.
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, Any, Generator, Optional
|
||||
|
||||
from discord.ext.commands import GroupMixin
|
||||
from redbot.core import commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import InterContext
|
||||
|
||||
try:
|
||||
import regex as re
|
||||
except ImportError:
|
||||
import re
|
||||
|
||||
|
||||
# ContextVar holding the current InterContext for the active interaction
|
||||
contexts: ContextVar["InterContext"] = ContextVar("userslash_contexts")
|
||||
|
||||
|
||||
def valid_app_name(name: str) -> str:
|
||||
"""Convert a display name into a valid slash-command name (lowercase, underscores, ≤32 chars)."""
|
||||
from discord.app_commands.commands import VALID_SLASH_COMMAND_NAME, validate_name
|
||||
|
||||
name = "_".join(
|
||||
re.findall(VALID_SLASH_COMMAND_NAME.pattern.strip("^$"), name.lower())
|
||||
)
|
||||
return validate_name(name[:32])
|
||||
|
||||
|
||||
class Thinking:
|
||||
"""Async context-manager / awaitable that defers the interaction response once."""
|
||||
|
||||
def __init__(self, ctx: "InterContext", *, ephemeral: bool = False):
|
||||
self.ctx = ctx
|
||||
self.ephemeral = ephemeral
|
||||
|
||||
def __await__(self) -> Generator[Any, Any, None]:
|
||||
ctx = self.ctx
|
||||
interaction = ctx._interaction
|
||||
if not ctx._deferring and not interaction.response.is_done():
|
||||
ctx._deferring = True
|
||||
return (
|
||||
yield from interaction.response.defer(
|
||||
ephemeral=self.ephemeral
|
||||
).__await__()
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
await self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def walk_aliases(
|
||||
group: GroupMixin[Any],
|
||||
/,
|
||||
*,
|
||||
parent: Optional[str] = "",
|
||||
show_hidden: bool = False,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Yield every reachable command name (including aliases) in *group*."""
|
||||
for name, command in group.all_commands.items():
|
||||
if command.qualified_name == "help":
|
||||
continue
|
||||
if not command.enabled or (not show_hidden and command.hidden):
|
||||
continue
|
||||
yield f"{parent}{name}"
|
||||
if isinstance(command, commands.GroupMixin):
|
||||
yield from walk_aliases(
|
||||
command, parent=f"{parent}{name} ", show_hidden=show_hidden
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue