EveCogs/userslash/__init__.py
everestmcarthur 8858470392 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
2026-05-14 20:14:33 +00:00

240 lines
9 KiB
Python

"""
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))