diff --git a/info.json b/info.json index 1ebecea..a40f167 100644 --- a/info.json +++ b/info.json @@ -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" } diff --git a/userslash/__init__.py b/userslash/__init__.py new file mode 100644 index 0000000..1e6b7a1 --- /dev/null +++ b/userslash/__init__.py @@ -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)) diff --git a/userslash/channel.py b/userslash/channel.py new file mode 100644 index 0000000..6351fe6 --- /dev/null +++ b/userslash/channel.py @@ -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) diff --git a/userslash/commands.py b/userslash/commands.py new file mode 100644 index 0000000..e5fbda4 --- /dev/null +++ b/userslash/commands.py @@ -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) + ) diff --git a/userslash/context.py b/userslash/context.py new file mode 100644 index 0000000..9a09ee9 --- /dev/null +++ b/userslash/context.py @@ -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" 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 + ) diff --git a/userslash/info.json b/userslash/info.json new file mode 100644 index 0000000..9e4f2ff --- /dev/null +++ b/userslash/info.json @@ -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"] +} diff --git a/userslash/message.py b/userslash/message.py new file mode 100644 index 0000000..32a8591 --- /dev/null +++ b/userslash/message.py @@ -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) diff --git a/userslash/patcher.py b/userslash/patcher.py new file mode 100644 index 0000000..71dfc92 --- /dev/null +++ b/userslash/patcher.py @@ -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) diff --git a/userslash/utils.py b/userslash/utils.py new file mode 100644 index 0000000..21817fd --- /dev/null +++ b/userslash/utils.py @@ -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 + )