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.",
|
"install_msg": "Welcome to **EveCogs**! Use `[p]cog list` to see available cogs.",
|
||||||
"name": "EveCogs",
|
"name": "EveCogs",
|
||||||
"short": "A collection of advanced cogs for Red-DiscordBot.",
|
"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"
|
"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